--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit fa46dc4ce9ab50cbe66a7a9e4b3bca0140102345
Parents : f46e702
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-24T06:44:30-05:00
feat: implement Windows AppContainer support
Changes
38 files changed, 2009 insertions(+), 28 deletions(-)
Diff
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1ec63f2e..0b6cd439 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -212,6 +212,9 @@ jobs:
- name: Verify bleak bundled in frozen backend
run: bash scripts/ci/github-verify-frozen-bleak.sh build/exe
+ - name: Verify FS sandbox modules bundled in frozen backend
+ run: bash scripts/ci/github-verify-frozen-sandbox.sh build/exe
+
- name: Verify frozen backend has no package bloat
run: bash scripts/ci/verify-package-contents.sh frozen build/exe
@@ -254,6 +257,8 @@ jobs:
test -n "$(ls -A .artifacts/linux-build-check/build/exe)"
bash scripts/ci/github-verify-frozen-bleak.sh \
.artifacts/linux-build-check/build/exe
+ bash scripts/ci/github-verify-frozen-sandbox.sh \
+ .artifacts/linux-build-check/build/exe
bash scripts/ci/verify-package-contents.sh frozen \
.artifacts/linux-build-check/build/exe
echo "Linux build artifact download + content validation passed."
@@ -398,3 +403,5 @@ jobs:
set -o pipefail
uv run python -m meshchatx.meshchat --self-check --headless 2>&1 | tee self-check-out.txt
grep -E '^\[OK\][[:space:]]+Storage Lock' self-check-out.txt
+ grep -E '^\[OK\][[:space:]]+FS Sandbox Modules' self-check-out.txt
+ grep -E '^\[OK\][[:space:]]+HTTP Server Security' self-check-out.txt
diff --git a/docs/agents/skills/electron-frozen-packaging/SKILL.md b/docs/agents/skills/electron-frozen-packaging/SKILL.md
index c718a686..b1c87989 100644
--- a/docs/agents/skills/electron-frozen-packaging/SKILL.md
+++ b/docs/agents/skills/electron-frozen-packaging/SKILL.md
@@ -13,6 +13,7 @@ Package and recover the desktop shell correctly: frozen subprocess re-entry, loa
- In frozen builds, `sys.executable` **is** MeshChatX.
- Never spawn `python -m …` or `python -c …` for bots, rnsh, LXMFy, or self-check probes from the packaged app.
- Use `--meshchatx-run-module <module>` so helpers re-enter the same binary without launching a second full app (storage lock collision).
+- On Windows, Electron starts the long-lived backend through `--meshchatx-run-module meshchatx.src.backend.appcontainer_launcher` unless `MESHCHAT_APPCONTAINER=0`. The launcher CreateProcess-es the real backend into an LPAC AppContainer. Orphan kill must use process-tree termination (`taskkill /T`) so both launcher and child exit.
## Loading and navigation
diff --git a/docs/agents/skills/landlock-sqlite/SKILL.md b/docs/agents/skills/landlock-sqlite/SKILL.md
index fe7a76de..c3a5bc99 100644
--- a/docs/agents/skills/landlock-sqlite/SKILL.md
+++ b/docs/agents/skills/landlock-sqlite/SKILL.md
@@ -1,44 +1,54 @@
# Skill: landlock-sqlite
-Landlock + SQLite conversation-load failures (temp_store, slim queries, memory pressure).
+Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store, slim queries, memory pressure).
-# MeshChatX Landlock + SQLite
+# MeshChatX FS sandbox + SQLite
## Symptoms
- `/api/v1/lxmf/conversations` or `/api/v1/notifications` return 500/503
- Logs show `sqlite3.OperationalError: unable to open database file`
-- Happens after Landlock enables, often with large message `fields` / base64 blobs
+- Happens after Landlock or Windows AppContainer enables, often with large message `fields` / base64 blobs
## Root causes (priority order)
1. Worker-thread connections missing `PRAGMA temp_store=MEMORY` (`DatabaseProvider._configure_connection`)
2. Conversation SELECT pulling full `content` / `fields`
-3. Memory-pressure switching to `temp_store=FILE` under Landlock
+3. Memory-pressure switching to `temp_store=FILE` under a filesystem sandbox
4. Identity context not ready (should be 503, not 500)
## Required behavior
- Default: `temp_store=MEMORY` on every new connection
-- Landlock active + memory pressure: shrink cache/mmap, **keep MEMORY temp**
-- Non-Landlock memory pressure may use FILE temp + storage-local `sqlite-tmp` TMPDIR
+- FS sandbox active (`landlock_active` or `appcontainer_active` / `fs_sandbox_active`) + memory pressure: shrink cache/mmap, **keep MEMORY temp**
+- Non-sandbox memory pressure may use FILE temp + storage-local `sqlite-tmp` TMPDIR
- List queries: `substr(content, 1, 240)` and SQL `instr` flags for attachments
- API: map OperationalError / unable-to-open / locked to **503** with retryable message
+## Windows counterpart
+
+- Module: `meshchatx/src/backend/appcontainer_sandbox.py`
+- Launcher: `meshchatx/src/backend/appcontainer_launcher.py` via `--meshchatx-run-module`
+- Electron win32 spawn uses the launcher unless `MESHCHAT_APPCONTAINER=0`
+- Docs: `meshchatx-docs/en/platform-guides/windows-sandbox.md`
+
## Verification
```bash
-uv run pytest tests/backend/test_sqlite_landlock_temp_store.py tests/backend/test_sqlite_memory_pressure.py tests/backend/test_landlock_sandbox.py -q
+uv run pytest tests/backend/test_sqlite_landlock_temp_store.py tests/backend/test_sqlite_memory_pressure.py tests/backend/test_landlock_sandbox.py tests/backend/test_appcontainer_sandbox.py tests/backend/test_self_check.py -q
+pnpm exec vitest run tests/frontend/i18n.test.js
+bash scripts/ci/github-verify-frozen-sandbox.sh build/exe
```
-For live stress, run Landlock in a **subprocess** (sandbox applies once per process). Expect FILE temp complex queries to fail under Landlock. MEMORY must pass.
+For live stress, run Landlock in a **subprocess** (sandbox applies once per process). Expect FILE temp complex queries to fail under Landlock. MEMORY must pass. On Windows, confirm `appcontainer_active` via `/api/v1/server/security`. Headless self-check includes `FS Sandbox Modules` and requires AppContainer status fields on `/api/v1/server/security`.
## Key files
- `meshchatx/src/backend/database/provider.py`
- `meshchatx/src/backend/database/__init__.py`
- `meshchatx/src/backend/memory_pressure.py`
-- `meshchatx/src/backend/message_handler.py`
- `meshchatx/src/backend/landlock_sandbox.py`
+- `meshchatx/src/backend/appcontainer_sandbox.py`
+- `meshchatx/src/backend/appcontainer_launcher.py`
- `meshchatx/src/backend/seccomp_sandbox.py` (syscall denylist after Landlock)
- `meshchatx/meshchat.py` (conversations/notifications error mapping)
diff --git a/docs/en/getting-started.md b/docs/en/getting-started.md
index 1eb409b1..94cc6572 100644
--- a/docs/en/getting-started.md
+++ b/docs/en/getting-started.md
@@ -92,6 +92,6 @@ Legacy upstream data may still exist under `~/.reticulum-meshchat/`. Migration t
- **Architecture and design** explains backend managers, identity scoping, and the API model.
- **LXMF messaging** and **Audio calls** describe day-to-day communication features.
- **Reticulum interfaces** explains how your node joins the mesh.
-- Platform guides under **Platform guides** cover Raspberry Pi, Android Termux, Meta Quest, and Linux sandboxing.
+- Platform guides under **Platform guides** cover Raspberry Pi, Android Termux, Meta Quest, Linux sandboxing, and Windows AppContainer sandboxing.
For protocol-level detail, open the **Reticulum** tab in Documentation or visit the [Reticulum manual](https://reticulum.network/manual/) online.
diff --git a/electron/backendProcess.js b/electron/backendProcess.js
index 3cec3c53..84a8b988 100644
--- a/electron/backendProcess.js
+++ b/electron/backendProcess.js
@@ -194,6 +194,32 @@ function createBackendProcessManager(deps) {
};
}
+ function shouldUseAppContainerLauncher() {
+ if (process.platform !== "win32") {
+ return false;
+ }
+ const raw = process.env.MESHCHAT_APPCONTAINER;
+ if (raw !== undefined && raw !== null && String(raw).trim() !== "") {
+ const val = String(raw).trim().toLowerCase();
+ if (["false", "0", "no", "off"].includes(val)) {
+ return false;
+ }
+ if (["true", "1", "yes", "on"].includes(val)) {
+ return true;
+ }
+ }
+ // Default on for Windows desktop shells (Landlock equivalent).
+ return true;
+ }
+
+ function buildSpawnArgs(extraArgs = []) {
+ const backendArgs = buildBackendArgs(extraArgs);
+ if (!shouldUseAppContainerLauncher()) {
+ return backendArgs;
+ }
+ return ["--meshchatx-run-module", "meshchatx.src.backend.appcontainer_launcher", ...backendArgs];
+ }
+
async function spawnBackend(exePath, integrityStatusRef) {
if (!exePath) {
throw new Error("Backend executable path is not set.");
@@ -223,7 +249,11 @@ function createBackendProcessManager(deps) {
);
}
- const proc = spawnFn(exePath, buildBackendArgs(), {
+ if (shouldUseAppContainerLauncher()) {
+ log("Starting Windows backend via AppContainer launcher.");
+ }
+
+ const proc = spawnFn(exePath, buildSpawnArgs(), {
env: buildSpawnEnv(),
windowsHide: true,
});
@@ -308,7 +338,7 @@ function createBackendProcessManager(deps) {
return await new Promise((resolve) => {
const stdoutChunks = [];
const stderrChunks = [];
- const proc = spawnFn(resolvedExePath, buildBackendArgs(extraArgs), {
+ const proc = spawnFn(resolvedExePath, buildSpawnArgs(extraArgs), {
env: buildSpawnEnv(),
windowsHide: true,
});
diff --git a/electron/main.js b/electron/main.js
index 7404684e..17ee394e 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -980,6 +980,17 @@ app.whenReady().then(async () => {
callback({ responseHeaders });
});
+ // Keep UI downloads under Downloads/MeshChatX so AppContainer ACLs and
+ // reveal-in-folder stay on an app-owned exchange dir, not all of Downloads.
+ try {
+ const exchangeDownloads = path.join(app.getPath("downloads"), "MeshChatX");
+ fs.mkdirSync(exchangeDownloads, { recursive: true });
+ session.defaultSession.setDownloadPath(exchangeDownloads);
+ log(`Download path set to ${exchangeDownloads}`);
+ } catch (error) {
+ log(`Failed to set MeshChatX download path: ${error && error.message ? error.message : error}`);
+ }
+
// Log Hardware Acceleration status (New in Electron 39)
const isHardwareAccelerationEnabled = app.isHardwareAccelerationEnabled();
log(`Hardware Acceleration Enabled: ${isHardwareAccelerationEnabled}`);
diff --git a/electron/shellPathGuard.js b/electron/shellPathGuard.js
index 0709f7d6..1313190a 100644
--- a/electron/shellPathGuard.js
+++ b/electron/shellPathGuard.js
@@ -71,8 +71,19 @@ function isAllowedShellPath(targetPath, ctx) {
add(ctx.getDefaultReticulumConfigDir());
add(ctx.app.getPath("userData"));
add(ctx.app.getPath("temp"));
- add(ctx.app.getPath("downloads"));
- add(ctx.app.getPath("documents"));
+ // Prefer app-owned exchange folders. Keep parent Downloads/Documents so
+ // reveal-in-folder still works for browser saves that landed outside them.
+ const downloads = ctx.app.getPath("downloads");
+ const documents = ctx.app.getPath("documents");
+ add(path.join(downloads, "MeshChatX"));
+ add(path.join(documents, "MeshChatX"));
+ try {
+ add(path.join(ctx.app.getPath("pictures"), "MeshChatX"));
+ } catch {
+ // pictures may be unavailable in some Electron test fakes
+ }
+ add(downloads);
+ add(documents);
const portable = process.env.PORTABLE_EXECUTABLE_DIR;
if (portable) {
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 10934b12..7a8fc6cd 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 487a547b..aa921d2d 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -108,6 +108,14 @@ from meshchatx.src.backend.landlock_sandbox import (
landlock_kernel_supported,
landlock_requested,
)
+from meshchatx.src.backend.appcontainer_sandbox import (
+ apply_windows_process_mitigations,
+ appcontainer_auto_enabled,
+ appcontainer_disabled_by_env,
+ appcontainer_requested,
+ appcontainer_supported,
+ is_appcontainer_child,
+)
from meshchatx.src.backend.seccomp_sandbox import (
apply_seccomp_sandbox,
seccomp_auto_enabled,
@@ -598,6 +606,7 @@ class ReticulumMeshChat:
self.listen_port: int | None = None
self.use_https: bool = True
self.landlock_active: bool = False
+ self.appcontainer_active: bool = False
self.seccomp_active: bool = False
self._pending_identity = identity
self._network_setup_lock = threading.Lock()
@@ -1243,6 +1252,7 @@ class ReticulumMeshChat:
self.storage_path or self.storage_dir,
)
temp_fs_result = self_check_mod.check_temp_filesystem()
+ fs_sandbox_result = self_check_mod.check_fs_sandbox()
public_assets_result = self_check_mod.check_public_assets(self.get_public_path)
lxmf_result = self_check_mod.check_lxmf_router(
self.message_router,
@@ -1283,6 +1293,7 @@ class ReticulumMeshChat:
"imports_good": imports_result,
"storage_lock_good": storage_lock_result,
"temp_fs_good": temp_fs_result,
+ "fs_sandbox_good": fs_sandbox_result,
"public_assets_good": public_assets_result,
"lxmf_router_good": lxmf_result,
"subprocess_good": subprocess_result,
@@ -5031,6 +5042,12 @@ class ReticulumMeshChat:
"landlock_auto_enabled": landlock_auto_enabled(),
"landlock_disabled_by_env": landlock_disabled_by_env(),
"landlock_active": self.landlock_active,
+ "appcontainer_supported": appcontainer_supported(),
+ "appcontainer_requested": appcontainer_requested(),
+ "appcontainer_auto_enabled": appcontainer_auto_enabled(),
+ "appcontainer_disabled_by_env": appcontainer_disabled_by_env(),
+ "appcontainer_active": self.appcontainer_active,
+ "fs_sandbox_active": bool(self.landlock_active or self.appcontainer_active),
"seccomp_kernel_supported": seccomp_kernel_supported(),
"seccomp_requested": seccomp_requested(),
"seccomp_auto_enabled": seccomp_auto_enabled(),
@@ -5038,6 +5055,10 @@ class ReticulumMeshChat:
"seccomp_active": self.seccomp_active,
}
+ @property
+ def fs_sandbox_active(self) -> bool:
+ return bool(self.landlock_active or self.appcontainer_active)
+
def get_routes(self):
routes = web.RouteTableDef()
self._define_routes(routes)
@@ -10149,6 +10170,27 @@ def main():
if _maybe_run_embedded_module():
return
+ # Windows: mirror Linux Landlock by supervising the real process in an
+ # AppContainer when requested. Electron already enters via the launcher
+ # module. Skip when already inside the container or when this process is
+ # the launcher supervisor (MESHCHAT_APPCONTAINER_LAUNCHER=1).
+ if (
+ sys.platform == "win32"
+ and appcontainer_requested()
+ and not is_appcontainer_child()
+ and os.environ.get("MESHCHAT_APPCONTAINER_LAUNCHER", "").strip()
+ not in (
+ "1",
+ "true",
+ "yes",
+ "on",
+ )
+ ):
+ from meshchatx.src.backend.appcontainer_launcher import run_launcher
+
+ os.environ["MESHCHAT_APPCONTAINER_LAUNCHER"] = "1"
+ raise SystemExit(run_launcher(sys.argv[1:]))
+
# Initialize crash recovery system early to catch startup errors
recovery = CrashRecovery()
recovery.install()
@@ -10585,6 +10627,9 @@ def main():
print(f"Error: Snapshot not found at {snapshot_path}")
enable_https = not args.no_https
+ reticulum_meshchat.appcontainer_active = is_appcontainer_child()
+ if reticulum_meshchat.appcontainer_active:
+ apply_windows_process_mitigations()
reticulum_meshchat.landlock_active = apply_landlock_sandbox(
storage_dir=reticulum_meshchat.storage_dir,
reticulum_config_dir=reticulum_meshchat.reticulum_config_dir,
diff --git a/meshchatx/src/backend/appcontainer_launcher.py b/meshchatx/src/backend/appcontainer_launcher.py
new file mode 100644
index 00000000..70de48f3
--- /dev/null
+++ b/meshchatx/src/backend/appcontainer_launcher.py
@@ -0,0 +1,122 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Supervisor that launches the MeshChatX backend inside an AppContainer.
+
+Invoked as:
+
+ ReticulumMeshChatX.exe --meshchatx-run-module meshchatx.src.backend.appcontainer_launcher --headless ...
+
+After --meshchatx-run-module stripping, argv is the real backend argv.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+import tempfile
+
+from meshchatx.src.backend.appcontainer_sandbox import (
+ LaunchResult,
+ appcontainer_forced,
+ appcontainer_supported,
+ is_appcontainer_child,
+ launch_backend_sandboxed,
+)
+
+logger = logging.getLogger("meshchatx.appcontainer.launcher")
+
+
+def _parse_dir_flag(argv: list[str], flag: str) -> str | None:
+ for i, arg in enumerate(argv):
+ if arg == flag and i + 1 < len(argv):
+ return argv[i + 1]
+ if arg.startswith(flag + "="):
+ return arg.split("=", 1)[1]
+ return None
+
+
+def _resolve_paths(argv: list[str]) -> tuple[str | None, str | None, str | None]:
+ storage = _parse_dir_flag(argv, "--storage-dir") or os.environ.get(
+ "MESHCHAT_STORAGE_DIR"
+ )
+ reticulum = _parse_dir_flag(argv, "--reticulum-config-dir") or os.environ.get(
+ "MESHCHAT_RETICULUM_CONFIG_DIR"
+ )
+ log_dir = os.environ.get("MESHCHAT_LOG_DIR")
+ if not log_dir and storage:
+ log_dir = os.path.join(storage, "logs")
+ return storage, reticulum, log_dir
+
+
+def run_launcher(argv: list[str] | None = None) -> int:
+ """Launch the backend under AppContainer and return its exit code."""
+ if argv is None:
+ argv = sys.argv[1:]
+
+ if is_appcontainer_child():
+ print(
+ "error: appcontainer_launcher must not run inside an AppContainer child",
+ file=sys.stderr,
+ )
+ return 2
+
+ if sys.platform != "win32":
+ print(
+ "error: AppContainer launcher is only supported on Windows",
+ file=sys.stderr,
+ )
+ return 2
+
+ if not appcontainer_supported() and appcontainer_forced():
+ print(
+ "error: MESHCHAT_APPCONTAINER=1 but AppContainer APIs are unavailable",
+ file=sys.stderr,
+ )
+ return 2
+
+ exe = sys.executable
+ if not exe:
+ print("error: sys.executable is unset", file=sys.stderr)
+ return 2
+
+ storage_dir, reticulum_config_dir, log_dir = _resolve_paths(argv)
+ # Ensure temp exists before ACL grant.
+ try:
+ tempfile.gettempdir()
+ except Exception:
+ pass
+
+ result: LaunchResult = launch_backend_sandboxed(
+ exe,
+ list(argv),
+ storage_dir=storage_dir,
+ reticulum_config_dir=reticulum_config_dir,
+ log_dir=log_dir,
+ forced=appcontainer_forced(),
+ )
+
+ if result.fell_back:
+ logger.warning(
+ "AppContainer unavailable, ran unsandboxed backend (exit=%s)",
+ result.exit_code,
+ )
+ elif result.used_appcontainer:
+ logger.info("Backend exited from AppContainer (exit=%s)", result.exit_code)
+
+ if not result.ok:
+ print(
+ f"error: AppContainer launch failed: {result.error}",
+ file=sys.stderr,
+ )
+ return 1
+
+ return int(result.exit_code if result.exit_code is not None else 0)
+
+
+def main() -> None:
+ raise SystemExit(run_launcher())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/meshchatx/src/backend/appcontainer_sandbox.py b/meshchatx/src/backend/appcontainer_sandbox.py
new file mode 100644
index 00000000..1b8ebfa0
--- /dev/null
+++ b/meshchatx/src/backend/appcontainer_sandbox.py
@@ -0,0 +1,972 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Optional Windows AppContainer / LPAC filesystem sandbox for the backend.
+
+Mirrors Landlock intent on Win10 and Win11: deny ambient write access and
+grant RW only under MeshChatX storage, Reticulum config, logs, and temp.
+Unlike Landlock, policy is applied at CreateProcess time via a launcher.
+"""
+
+from __future__ import annotations
+
+import ctypes
+import logging
+import os
+import sys
+import tempfile
+from dataclasses import dataclass
+from typing import Callable
+
+logger = logging.getLogger("meshchatx.appcontainer")
+
+APPCONTAINER_PROFILE_NAME = "MeshChatX.Backend"
+APPCONTAINER_DISPLAY_NAME = "MeshChatX Backend"
+CHILD_ENV_FLAG = "MESHCHAT_APPCONTAINER_CHILD"
+ENV_VAR = "MESHCHAT_APPCONTAINER"
+# Dedicated exchange dirs under the user profile. Do not ACL-grant all of
+# Documents or Downloads. Attachments and exports use these subfolders only.
+USER_EXCHANGE_DIR_NAME = "MeshChatX"
+
+# Win32 constants
+_ERROR_ALREADY_EXISTS = 183
+_ERROR_SUCCESS = 0
+_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
+
+_GENERIC_READ = 0x80000000
+_GENERIC_WRITE = 0x40000000
+_GENERIC_EXECUTE = 0x20000000
+_GENERIC_ALL = 0x10000000
+_FILE_GENERIC_READ = 0x00120089
+_FILE_GENERIC_WRITE = 0x00120116
+_FILE_GENERIC_EXECUTE = 0x001200A0
+
+_GRANT_ACCESS = 1
+_REVOKE_ACCESS = 4
+_TRUSTEE_IS_SID = 0
+_TRUSTEE_IS_UNKNOWN = 0
+_SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3
+_SE_FILE_OBJECT = 1
+_DACL_SECURITY_INFORMATION = 0x00000004
+_PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000
+
+_SE_GROUP_ENABLED = 0x00000004
+
+_CREATE_UNICODE_ENVIRONMENT = 0x00000400
+_EXTENDED_STARTUPINFO_PRESENT = 0x00080000
+_CREATE_NO_WINDOW = 0x08000000
+
+# ProcThreadAttributeList numbers (Windows 8+)
+_PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009
+_PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY = 0x0002000E
+_PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT = 1
+
+# WELL_KNOWN_SID_TYPE for AppContainer network capabilities
+_WinCapabilityInternetClientSid = 85
+_WinCapabilityInternetClientServerSid = 86
+_WinCapabilityPrivateNetworkClientServerSid = 84
+
+_INFINITE = 0xFFFFFFFF
+_WAIT_OBJECT_0 = 0
+
+_PROCESS_MITIGATION_EXTENSION_POINT_DISABLE_POLICY = 6
+_PROCESS_MITIGATION_IMAGE_LOAD_POLICY = 10
+_PROCESS_MITIGATION_STRICT_HANDLE_CHECK_POLICY = 3
+
+
+@dataclass(frozen=True)
+class LaunchResult:
+ """Outcome of launching a process into an AppContainer."""
+
+ ok: bool
+ exit_code: int | None = None
+ error: str | None = None
+ used_appcontainer: bool = False
+ fell_back: bool = False
+
+
+class _SID_AND_ATTRIBUTES(ctypes.Structure):
+ _fields_ = [
+ ("Sid", ctypes.c_void_p),
+ ("Attributes", ctypes.c_ulong),
+ ]
+
+
+class _SECURITY_CAPABILITIES(ctypes.Structure):
+ _fields_ = [
+ ("AppContainerSid", ctypes.c_void_p),
+ ("Capabilities", ctypes.POINTER(_SID_AND_ATTRIBUTES)),
+ ("CapabilityCount", ctypes.c_ulong),
+ ("Reserved", ctypes.c_ulong),
+ ]
+
+
+class _STARTUPINFO(ctypes.Structure):
+ _fields_ = [
+ ("cb", ctypes.c_ulong),
+ ("lpReserved", ctypes.c_wchar_p),
+ ("lpDesktop", ctypes.c_wchar_p),
+ ("lpTitle", ctypes.c_wchar_p),
+ ("dwX", ctypes.c_ulong),
+ ("dwY", ctypes.c_ulong),
+ ("dwXSize", ctypes.c_ulong),
+ ("dwYSize", ctypes.c_ulong),
+ ("dwXCountChars", ctypes.c_ulong),
+ ("dwYCountChars", ctypes.c_ulong),
+ ("dwFillAttribute", ctypes.c_ulong),
+ ("dwFlags", ctypes.c_ulong),
+ ("wShowWindow", ctypes.c_ushort),
+ ("cbReserved2", ctypes.c_ushort),
+ ("lpReserved2", ctypes.c_void_p),
+ ("hStdInput", ctypes.c_void_p),
+ ("hStdOutput", ctypes.c_void_p),
+ ("hStdError", ctypes.c_void_p),
+ ]
+
+
+class _STARTUPINFOEX(ctypes.Structure):
+ _fields_ = [
+ ("StartupInfo", _STARTUPINFO),
+ ("lpAttributeList", ctypes.c_void_p),
+ ]
+
+
+class _PROCESS_INFORMATION(ctypes.Structure):
+ _fields_ = [
+ ("hProcess", ctypes.c_void_p),
+ ("hThread", ctypes.c_void_p),
+ ("dwProcessId", ctypes.c_ulong),
+ ("dwThreadId", ctypes.c_ulong),
+ ]
+
+
+class _TRUSTEE_W(ctypes.Structure):
+ _fields_ = [
+ ("pMultipleTrustee", ctypes.c_void_p),
+ ("MultipleTrusteeOperation", ctypes.c_ulong),
+ ("TrusteeForm", ctypes.c_ulong),
+ ("TrusteeType", ctypes.c_ulong),
+ ("ptstrName", ctypes.c_void_p),
+ ]
+
+
+class _EXPLICIT_ACCESS_W(ctypes.Structure):
+ _fields_ = [
+ ("grfAccessPermissions", ctypes.c_ulong),
+ ("grfAccessMode", ctypes.c_ulong),
+ ("grfInheritance", ctypes.c_ulong),
+ ("Trustee", _TRUSTEE_W),
+ ]
+
+
+class _PROCESS_MITIGATION_EXTENSION_POINT_DISABLE_POLICY(ctypes.Structure):
+ _fields_ = [("Flags", ctypes.c_ulong)]
+
+
+class _PROCESS_MITIGATION_IMAGE_LOAD_POLICY(ctypes.Structure):
+ _fields_ = [("Flags", ctypes.c_ulong)]
+
+
+class _PROCESS_MITIGATION_STRICT_HANDLE_CHECK_POLICY(ctypes.Structure):
+ _fields_ = [("Flags", ctypes.c_ulong)]
+
+
+_appcontainer_support_cached: bool | None = None
+
+
+def _env_override() -> bool | None:
+ raw = os.environ.get(ENV_VAR)
+ if raw is None:
+ return None
+ val = raw.strip().lower()
+ if val in ("false", "0", "no", "off"):
+ return False
+ if val in ("true", "1", "yes", "on"):
+ return True
+ return None
+
+
+def is_appcontainer_child() -> bool:
+ """Return True when this process was launched inside the AppContainer."""
+ raw = os.environ.get(CHILD_ENV_FLAG, "")
+ return raw.strip().lower() in ("1", "true", "yes", "on")
+
+
+def _windows_version_supported() -> bool:
+ if sys.platform != "win32":
+ return False
+ try:
+ ver = sys.getwindowsversion()
+ except AttributeError:
+ return False
+ # AppContainer exists since Win8. LPAC and packaging we rely on need Win10+.
+ return int(ver.major) >= 10
+
+
+def _probe_appcontainer_apis() -> bool:
+ if sys.platform != "win32":
+ return False
+ try:
+ userenv = ctypes.WinDLL("userenv", use_last_error=True)
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
+ except OSError:
+ return False
+ required = (
+ hasattr(userenv, "CreateAppContainerProfile"),
+ hasattr(userenv, "DeriveAppContainerSidFromAppContainerName"),
+ hasattr(userenv, "DeleteAppContainerProfile"),
+ hasattr(kernel32, "InitializeProcThreadAttributeList"),
+ hasattr(kernel32, "UpdateProcThreadAttribute"),
+ hasattr(kernel32, "DeleteProcThreadAttributeList"),
+ hasattr(advapi32, "SetEntriesInAclW"),
+ hasattr(advapi32, "GetNamedSecurityInfoW"),
+ hasattr(advapi32, "SetNamedSecurityInfoW"),
+ hasattr(advapi32, "CreateWellKnownSid"),
+ )
+ return all(required)
+
+
+def appcontainer_supported() -> bool:
+ global _appcontainer_support_cached
+ if _appcontainer_support_cached is not None:
+ return _appcontainer_support_cached
+ if sys.platform != "win32":
+ _appcontainer_support_cached = False
+ return False
+ if not _windows_version_supported():
+ _appcontainer_support_cached = False
+ return False
+ _appcontainer_support_cached = _probe_appcontainer_apis()
+ return _appcontainer_support_cached
+
+
+def appcontainer_requested() -> bool:
+ if sys.platform != "win32":
+ return False
+ override = _env_override()
+ if override is False:
+ return False
+ if override is True:
+ return True
+ return appcontainer_supported()
+
+
+def appcontainer_auto_enabled() -> bool:
+ return appcontainer_requested() and _env_override() is None
+
+
+def appcontainer_disabled_by_env() -> bool:
+ return _env_override() is False
+
+
+def appcontainer_forced() -> bool:
+ return _env_override() is True
+
+
+def _existing_dir(path: str | None) -> str | None:
+ if not path:
+ return None
+ resolved = os.path.abspath(os.path.expanduser(path))
+ if os.path.isdir(resolved):
+ return resolved
+ parent = os.path.dirname(resolved)
+ if parent and os.path.isdir(parent):
+ return parent
+ return None
+
+
+def _ensure_dir(path: str | None) -> str | None:
+ if not path:
+ return None
+ resolved = os.path.abspath(os.path.expanduser(path))
+ try:
+ os.makedirs(resolved, exist_ok=True)
+ except OSError:
+ return None
+ if os.path.isdir(resolved):
+ return resolved
+ return None
+
+
+def _windows_known_folder(folder_id: str) -> str | None:
+ """Resolve a Windows Known Folder GUID to a path (handles OneDrive redirects)."""
+ if sys.platform != "win32":
+ return None
+ try:
+ from ctypes import wintypes
+
+ class _GUID(ctypes.Structure):
+ _fields_ = [
+ ("Data1", wintypes.DWORD),
+ ("Data2", wintypes.WORD),
+ ("Data3", wintypes.WORD),
+ ("Data4", wintypes.BYTE * 8),
+ ]
+
+ def _parse_guid(value: str) -> _GUID:
+ hex_part = value.strip("{}")
+ parts = hex_part.split("-")
+ data4_hex = parts[3] + parts[4]
+ data4 = (wintypes.BYTE * 8)(
+ *[int(data4_hex[i : i + 2], 16) for i in range(0, 16, 2)]
+ )
+ return _GUID(
+ int(parts[0], 16),
+ int(parts[1], 16),
+ int(parts[2], 16),
+ data4,
+ )
+
+ shell32 = ctypes.WinDLL("shell32", use_last_error=True)
+ ole32 = ctypes.WinDLL("ole32", use_last_error=True)
+ path_ptr = ctypes.c_wchar_p()
+ fid = _parse_guid(folder_id)
+ # KF_FLAG_DEFAULT = 0
+ hr = shell32.SHGetKnownFolderPath(
+ ctypes.byref(fid),
+ 0,
+ None,
+ ctypes.byref(path_ptr),
+ )
+ if hr != 0 or not path_ptr.value:
+ return None
+ try:
+ return str(path_ptr.value)
+ finally:
+ ole32.CoTaskMemFree(path_ptr)
+ except Exception:
+ return None
+
+
+def _user_profile_dir() -> str | None:
+ if sys.platform == "win32":
+ for key in ("USERPROFILE", "HOME"):
+ raw = os.environ.get(key)
+ if raw:
+ return os.path.abspath(raw)
+ home = os.path.expanduser("~")
+ if home and home != "~":
+ return os.path.abspath(home)
+ return None
+
+
+def collect_user_exchange_roots(*, create: bool = True) -> list[str]:
+ """Return Documents/Downloads/Pictures MeshChatX subfolders for attachments.
+
+ Grants RW only under these app-owned exchange dirs, never the entire
+ Documents or Downloads tree.
+ """
+ roots: list[str] = []
+ profile = _user_profile_dir()
+
+ known = {
+ "documents": _windows_known_folder("{FDD39AD0-238F-46AF-ADB4-6C85480369C7}"),
+ "downloads": _windows_known_folder("{374DE290-123F-4565-9164-39C4925E467B}"),
+ "pictures": _windows_known_folder("{33E28130-4E1E-4676-835A-98395C3BC3BB}"),
+ }
+ # Fallback when Known Folder APIs are unavailable (tests / non-Windows).
+ if profile:
+ if not known["documents"]:
+ known["documents"] = os.path.join(profile, "Documents")
+ if not known["downloads"]:
+ known["downloads"] = os.path.join(profile, "Downloads")
+ if not known["pictures"]:
+ known["pictures"] = os.path.join(profile, "Pictures")
+
+ for parent in (known["documents"], known["downloads"], known["pictures"]):
+ if not parent:
+ continue
+ exchange = os.path.join(parent, USER_EXCHANGE_DIR_NAME)
+ if create:
+ ensured = _ensure_dir(exchange)
+ if ensured and ensured not in roots:
+ roots.append(ensured)
+ continue
+ existing = _existing_dir(exchange)
+ if existing and existing not in roots:
+ roots.append(existing)
+ return roots
+
+
+def collect_rw_roots(
+ storage_dir: str | None,
+ reticulum_config_dir: str | None,
+ log_dir: str | None,
+ *,
+ exe_dir: str | None = None,
+) -> list[str]:
+ """Collect directories that need Package-SID write access."""
+ paths: list[str] = []
+ for candidate in (
+ storage_dir,
+ reticulum_config_dir,
+ log_dir,
+ tempfile.gettempdir(),
+ os.environ.get("TMP"),
+ os.environ.get("TEMP"),
+ os.environ.get("TMPDIR"),
+ ):
+ existing = _existing_dir(candidate)
+ if existing and existing not in paths:
+ paths.append(existing)
+ # Ensure parents exist for storage/config/log even before first write.
+ for candidate in (storage_dir, reticulum_config_dir, log_dir):
+ if not candidate:
+ continue
+ resolved = os.path.abspath(os.path.expanduser(candidate))
+ try:
+ os.makedirs(resolved, exist_ok=True)
+ except OSError:
+ continue
+ if resolved not in paths:
+ paths.append(resolved)
+ for exchange in collect_user_exchange_roots(create=True):
+ if exchange not in paths:
+ paths.append(exchange)
+ return paths
+
+
+def collect_ro_roots(*, exe_dir: str | None = None) -> list[str]:
+ """Collect directories that need Package-SID read/execute access under LPAC."""
+ paths: list[str] = []
+ for candidate in (
+ exe_dir,
+ os.path.dirname(sys.executable) if sys.executable else None,
+ getattr(sys, "prefix", None),
+ getattr(sys, "base_prefix", None),
+ ):
+ existing = _existing_dir(candidate)
+ if existing and existing not in paths:
+ paths.append(existing)
+ return paths
+
+
+def _local_free(ptr: ctypes.c_void_p | int | None) -> None:
+ if not ptr:
+ return
+ try:
+ ctypes.windll.kernel32.LocalFree(ctypes.c_void_p(ptr))
+ except Exception:
+ pass
+
+
+def _create_capability_sid(well_known: int) -> ctypes.c_void_p | None:
+ advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
+ size = ctypes.c_ulong(0)
+ advapi32.CreateWellKnownSid(
+ well_known,
+ None,
+ None,
+ ctypes.byref(size),
+ )
+ if size.value == 0:
+ return None
+ buf = (ctypes.c_ubyte * size.value)()
+ ok = advapi32.CreateWellKnownSid(
+ well_known,
+ None,
+ ctypes.byref(buf),
+ ctypes.byref(size),
+ )
+ if not ok:
+ return None
+ # Keep buffer alive by attaching to a c_void_p holder via identity.
+ holder = ctypes.cast(buf, ctypes.c_void_p)
+ holder._sid_buffer = buf # type: ignore[attr-defined]
+ return holder
+
+
+def ensure_appcontainer_profile(
+ profile_name: str = APPCONTAINER_PROFILE_NAME,
+) -> ctypes.c_void_p:
+ """Create or derive the AppContainer package SID for profile_name."""
+ userenv = ctypes.WinDLL("userenv", use_last_error=True)
+ sid = ctypes.c_void_p()
+ hr = userenv.CreateAppContainerProfile(
+ ctypes.c_wchar_p(profile_name),
+ ctypes.c_wchar_p(profile_name),
+ ctypes.c_wchar_p(APPCONTAINER_DISPLAY_NAME),
+ None,
+ 0,
+ ctypes.byref(sid),
+ )
+ # HRESULT: S_OK (0) or HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS)
+ if hr == 0 and sid.value:
+ return sid
+ # Already exists or create returned SID unset: derive it.
+ if sid.value:
+ try:
+ ctypes.windll.kernel32.LocalFree(sid)
+ except Exception:
+ pass
+ sid = ctypes.c_void_p()
+ hr = userenv.DeriveAppContainerSidFromAppContainerName(
+ ctypes.c_wchar_p(profile_name),
+ ctypes.byref(sid),
+ )
+ if hr != 0 or not sid.value:
+ err = ctypes.get_last_error()
+ raise OSError(
+ err, f"DeriveAppContainerSidFromAppContainerName failed: hr={hr} err={err}"
+ )
+ return sid
+
+
+def delete_appcontainer_profile(profile_name: str = APPCONTAINER_PROFILE_NAME) -> None:
+ userenv = ctypes.WinDLL("userenv", use_last_error=True)
+ hr = userenv.DeleteAppContainerProfile(ctypes.c_wchar_p(profile_name))
+ if hr not in (0,):
+ # Best effort cleanup. Profile may be in use.
+ logger.debug("DeleteAppContainerProfile hr=%s", hr)
+
+
+def _set_path_access(
+ path: str, sid: ctypes.c_void_p, access_mask: int, mode: int
+) -> None:
+ advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
+ ea = _EXPLICIT_ACCESS_W()
+ ea.grfAccessPermissions = access_mask
+ ea.grfAccessMode = mode
+ ea.grfInheritance = _SUB_CONTAINERS_AND_OBJECTS_INHERIT
+ ea.Trustee.pMultipleTrustee = None
+ ea.Trustee.MultipleTrusteeOperation = 0
+ ea.Trustee.TrusteeForm = _TRUSTEE_IS_SID
+ ea.Trustee.TrusteeType = _TRUSTEE_IS_UNKNOWN
+ ea.Trustee.ptstrName = sid
+
+ # Merge with the existing DACL so we do not wipe user or system ACEs.
+ p_sd = ctypes.c_void_p()
+ p_dacl = ctypes.c_void_p()
+ get_status = advapi32.GetNamedSecurityInfoW(
+ ctypes.c_wchar_p(path),
+ _SE_FILE_OBJECT,
+ _DACL_SECURITY_INFORMATION,
+ None,
+ None,
+ ctypes.byref(p_dacl),
+ None,
+ ctypes.byref(p_sd),
+ )
+ if get_status != _ERROR_SUCCESS:
+ err = ctypes.get_last_error()
+ raise OSError(
+ err, f"GetNamedSecurityInfoW failed for {path}: status={get_status}"
+ )
+
+ new_acl = ctypes.c_void_p()
+ try:
+ status = advapi32.SetEntriesInAclW(
+ 1,
+ ctypes.byref(ea),
+ p_dacl,
+ ctypes.byref(new_acl),
+ )
+ if status != _ERROR_SUCCESS or not new_acl.value:
+ err = ctypes.get_last_error()
+ raise OSError(err, f"SetEntriesInAclW failed for {path}: status={status}")
+
+ result = advapi32.SetNamedSecurityInfoW(
+ ctypes.c_wchar_p(path),
+ _SE_FILE_OBJECT,
+ _DACL_SECURITY_INFORMATION,
+ None,
+ None,
+ new_acl,
+ None,
+ )
+ if result != _ERROR_SUCCESS:
+ err = ctypes.get_last_error()
+ raise OSError(
+ err, f"SetNamedSecurityInfoW failed for {path}: result={result}"
+ )
+ finally:
+ _local_free(new_acl.value)
+ _local_free(p_sd.value)
+
+
+def grant_path_access(sid: ctypes.c_void_p, path: str, *, write: bool) -> None:
+ if write:
+ mask = _GENERIC_READ | _GENERIC_WRITE | _GENERIC_EXECUTE | _GENERIC_ALL
+ else:
+ mask = _GENERIC_READ | _GENERIC_EXECUTE
+ _set_path_access(path, sid, mask, _GRANT_ACCESS)
+
+
+def revoke_path_access(sid: ctypes.c_void_p, path: str) -> None:
+ try:
+ _set_path_access(
+ path,
+ sid,
+ _GENERIC_READ | _GENERIC_WRITE | _GENERIC_EXECUTE | _GENERIC_ALL,
+ _REVOKE_ACCESS,
+ )
+ except OSError as exc:
+ logger.debug("revoke_path_access %s: %s", path, exc)
+
+
+def _build_command_line(exe: str, args: list[str]) -> str:
+ parts = [exe, *args]
+
+ def quote(part: str) -> str:
+ if not part:
+ return '""'
+ if any(ch in part for ch in (" ", "\t", '"')):
+ escaped = part.replace('"', '\\"')
+ return f'"{escaped}"'
+ return part
+
+ return " ".join(quote(p) for p in parts)
+
+
+def _wait_process(handle: ctypes.c_void_p) -> int:
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ wait = kernel32.WaitForSingleObject(handle, _INFINITE)
+ if wait != _WAIT_OBJECT_0:
+ raise OSError(ctypes.get_last_error(), "WaitForSingleObject failed")
+ code = ctypes.c_ulong()
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
+ raise OSError(ctypes.get_last_error(), "GetExitCodeProcess failed")
+ return int(code.value)
+
+
+def create_process_in_appcontainer(
+ exe: str,
+ args: list[str],
+ *,
+ env: dict[str, str] | None = None,
+ use_lpac: bool = True,
+ cwd: str | None = None,
+) -> tuple[ctypes.c_void_p, ctypes.c_void_p, int]:
+ """Create a child process inside the MeshChatX AppContainer profile.
+
+ Returns (hProcess, hThread, pid). Caller must CloseHandle both handles.
+ """
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ sid = ensure_appcontainer_profile()
+
+ capability_types = (
+ _WinCapabilityInternetClientSid,
+ _WinCapabilityInternetClientServerSid,
+ _WinCapabilityPrivateNetworkClientServerSid,
+ )
+ capability_holders: list[ctypes.c_void_p] = []
+ for wk in capability_types:
+ cap = _create_capability_sid(wk)
+ if cap is not None and cap.value:
+ capability_holders.append(cap)
+
+ caps_array = (_SID_AND_ATTRIBUTES * max(1, len(capability_holders)))()
+ for i, holder in enumerate(capability_holders):
+ caps_array[i].Sid = holder
+ caps_array[i].Attributes = _SE_GROUP_ENABLED
+
+ sec_caps = _SECURITY_CAPABILITIES()
+ sec_caps.AppContainerSid = sid
+ if capability_holders:
+ sec_caps.Capabilities = ctypes.cast(
+ caps_array, ctypes.POINTER(_SID_AND_ATTRIBUTES)
+ )
+ sec_caps.CapabilityCount = len(capability_holders)
+ else:
+ sec_caps.Capabilities = None
+ sec_caps.CapabilityCount = 0
+ sec_caps.Reserved = 0
+
+ attr_count = 2 if use_lpac else 1
+ size = ctypes.c_size_t(0)
+ kernel32.InitializeProcThreadAttributeList(None, attr_count, 0, ctypes.byref(size))
+ if size.value == 0:
+ raise OSError(
+ ctypes.get_last_error(), "InitializeProcThreadAttributeList size failed"
+ )
+ attr_buf = (ctypes.c_ubyte * size.value)()
+ attr_list = ctypes.cast(attr_buf, ctypes.c_void_p)
+ if not kernel32.InitializeProcThreadAttributeList(
+ attr_list, attr_count, 0, ctypes.byref(size)
+ ):
+ raise OSError(
+ ctypes.get_last_error(), "InitializeProcThreadAttributeList failed"
+ )
+
+ try:
+ if not kernel32.UpdateProcThreadAttribute(
+ attr_list,
+ 0,
+ _PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES,
+ ctypes.byref(sec_caps),
+ ctypes.sizeof(sec_caps),
+ None,
+ None,
+ ):
+ raise OSError(
+ ctypes.get_last_error(),
+ "UpdateProcThreadAttribute SECURITY_CAPABILITIES failed",
+ )
+
+ lpac_policy = ctypes.c_ulong(_PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT)
+ if use_lpac:
+ if not kernel32.UpdateProcThreadAttribute(
+ attr_list,
+ 0,
+ _PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY,
+ ctypes.byref(lpac_policy),
+ ctypes.sizeof(lpac_policy),
+ None,
+ None,
+ ):
+ raise OSError(
+ ctypes.get_last_error(),
+ "UpdateProcThreadAttribute LPAC policy failed",
+ )
+
+ siex = _STARTUPINFOEX()
+ siex.StartupInfo.cb = ctypes.sizeof(siex)
+ siex.lpAttributeList = attr_list
+
+ pi = _PROCESS_INFORMATION()
+ cmdline = ctypes.create_unicode_buffer(_build_command_line(exe, args))
+
+ child_env = dict(os.environ if env is None else env)
+ child_env[CHILD_ENV_FLAG] = "1"
+ child_env.pop("MESHCHAT_APPCONTAINER_LAUNCHER", None)
+ # Build environment block (null-separated, double-null terminated).
+ env_block = "\0".join(f"{k}={v}" for k, v in child_env.items()) + "\0\0"
+ env_buf = ctypes.create_unicode_buffer(env_block)
+
+ creation_flags = (
+ _EXTENDED_STARTUPINFO_PRESENT
+ | _CREATE_UNICODE_ENVIRONMENT
+ | _CREATE_NO_WINDOW
+ )
+ ok = kernel32.CreateProcessW(
+ ctypes.c_wchar_p(exe),
+ cmdline,
+ None,
+ None,
+ True,
+ creation_flags,
+ env_buf,
+ ctypes.c_wchar_p(cwd) if cwd else None,
+ ctypes.byref(siex),
+ ctypes.byref(pi),
+ )
+ if not ok:
+ raise OSError(ctypes.get_last_error(), "CreateProcessW AppContainer failed")
+ return pi.hProcess, pi.hThread, int(pi.dwProcessId)
+ finally:
+ kernel32.DeleteProcThreadAttributeList(attr_list)
+ # Free derived package SID after CreateProcess has copied capabilities.
+ try:
+ ctypes.windll.kernel32.LocalFree(sid)
+ except Exception:
+ pass
+
+
+def create_process_unsandboxed(
+ exe: str,
+ args: list[str],
+ *,
+ env: dict[str, str] | None = None,
+ cwd: str | None = None,
+) -> tuple[ctypes.c_void_p, ctypes.c_void_p, int]:
+ """Create a normal child process (auto-fallback path)."""
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ si = _STARTUPINFO()
+ si.cb = ctypes.sizeof(si)
+ pi = _PROCESS_INFORMATION()
+ cmdline = ctypes.create_unicode_buffer(_build_command_line(exe, args))
+ child_env = dict(os.environ if env is None else env)
+ child_env.pop(CHILD_ENV_FLAG, None)
+ child_env.pop("MESHCHAT_APPCONTAINER_LAUNCHER", None)
+ env_block = "\0".join(f"{k}={v}" for k, v in child_env.items()) + "\0\0"
+ env_buf = ctypes.create_unicode_buffer(env_block)
+ ok = kernel32.CreateProcessW(
+ ctypes.c_wchar_p(exe),
+ cmdline,
+ None,
+ None,
+ True,
+ _CREATE_UNICODE_ENVIRONMENT | _CREATE_NO_WINDOW,
+ env_buf,
+ ctypes.c_wchar_p(cwd) if cwd else None,
+ ctypes.byref(si),
+ ctypes.byref(pi),
+ )
+ if not ok:
+ raise OSError(ctypes.get_last_error(), "CreateProcessW unsandboxed failed")
+ return pi.hProcess, pi.hThread, int(pi.dwProcessId)
+
+
+def close_handle(handle: ctypes.c_void_p | int | None) -> None:
+ if not handle:
+ return
+ try:
+ ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(
+ ctypes.c_void_p(handle)
+ )
+ except Exception:
+ pass
+
+
+def launch_backend_sandboxed(
+ exe: str,
+ args: list[str],
+ *,
+ storage_dir: str | None,
+ reticulum_config_dir: str | None,
+ log_dir: str | None,
+ forced: bool | None = None,
+ use_lpac: bool = True,
+) -> LaunchResult:
+ """Grant ACLs, launch into AppContainer, wait, revoke ACLs.
+
+ Auto mode (forced=False): on AppContainer failure, fall back to unsandboxed.
+ Forced mode: return error without fallback.
+ """
+ if forced is None:
+ forced = appcontainer_forced()
+
+ rw_roots = collect_rw_roots(storage_dir, reticulum_config_dir, log_dir)
+ ro_roots = collect_ro_roots(exe_dir=os.path.dirname(exe) if exe else None)
+ sid: ctypes.c_void_p | None = None
+ granted: list[tuple[str, bool]] = []
+
+ try:
+ sid = ensure_appcontainer_profile()
+ for path in rw_roots:
+ grant_path_access(sid, path, write=True)
+ granted.append((path, True))
+ for path in ro_roots:
+ if path in rw_roots:
+ continue
+ grant_path_access(sid, path, write=False)
+ granted.append((path, False))
+
+ h_process, h_thread, _pid = create_process_in_appcontainer(
+ exe,
+ args,
+ use_lpac=use_lpac,
+ )
+ try:
+ close_handle(h_thread)
+ exit_code = _wait_process(h_process)
+ finally:
+ close_handle(h_process)
+ return LaunchResult(
+ ok=True,
+ exit_code=exit_code,
+ used_appcontainer=True,
+ fell_back=False,
+ )
+ except OSError as exc:
+ logger.exception("AppContainer launch failed: %s", exc)
+ if forced:
+ return LaunchResult(
+ ok=False,
+ error=str(exc),
+ used_appcontainer=False,
+ fell_back=False,
+ )
+ logger.warning("Falling back to unsandboxed backend launch")
+ try:
+ h_process, h_thread, _pid = create_process_unsandboxed(exe, args)
+ try:
+ close_handle(h_thread)
+ exit_code = _wait_process(h_process)
+ finally:
+ close_handle(h_process)
+ return LaunchResult(
+ ok=True,
+ exit_code=exit_code,
+ used_appcontainer=False,
+ fell_back=True,
+ )
+ except OSError as fallback_exc:
+ return LaunchResult(
+ ok=False,
+ error=str(fallback_exc),
+ used_appcontainer=False,
+ fell_back=True,
+ )
+ finally:
+ if sid is not None:
+ for path, _write in reversed(granted):
+ revoke_path_access(sid, path)
+ try:
+ if sys.platform == "win32":
+ ctypes.windll.kernel32.LocalFree(sid)
+ except Exception:
+ pass
+
+
+def apply_windows_process_mitigations() -> bool:
+ """Apply compatible process mitigations inside the sandboxed child.
+
+ Does not enable NoChildProcessCreation because bots, rnsh, and rnx still
+ re-enter via --meshchatx-run-module.
+ """
+ if sys.platform != "win32":
+ return False
+ if not is_appcontainer_child():
+ return False
+ try:
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ except OSError:
+ return False
+ if not hasattr(kernel32, "SetProcessMitigationPolicy"):
+ return False
+
+ applied = False
+
+ # DisableExtensionPoints = 1
+ ext = _PROCESS_MITIGATION_EXTENSION_POINT_DISABLE_POLICY()
+ ext.Flags = 0x1
+ if kernel32.SetProcessMitigationPolicy(
+ _PROCESS_MITIGATION_EXTENSION_POINT_DISABLE_POLICY,
+ ctypes.byref(ext),
+ ctypes.sizeof(ext),
+ ):
+ applied = True
+ else:
+ logger.debug(
+ "SetProcessMitigationPolicy extension-point failed: %s",
+ ctypes.get_last_error(),
+ )
+
+ # PreferSystem32Images = 1 (bit 2), NoRemoteImages = 1 (bit 0) when compatible
+ img = _PROCESS_MITIGATION_IMAGE_LOAD_POLICY()
+ img.Flags = 0x1 | 0x4
+ if kernel32.SetProcessMitigationPolicy(
+ _PROCESS_MITIGATION_IMAGE_LOAD_POLICY,
+ ctypes.byref(img),
+ ctypes.sizeof(img),
+ ):
+ applied = True
+ else:
+ logger.debug(
+ "SetProcessMitigationPolicy image-load failed: %s",
+ ctypes.get_last_error(),
+ )
+
+ # RaiseExceptionOnInvalidHandleReference | HandleExceptionsPermanently
+ strict = _PROCESS_MITIGATION_STRICT_HANDLE_CHECK_POLICY()
+ strict.Flags = 0x1 | 0x2
+ if kernel32.SetProcessMitigationPolicy(
+ _PROCESS_MITIGATION_STRICT_HANDLE_CHECK_POLICY,
+ ctypes.byref(strict),
+ ctypes.sizeof(strict),
+ ):
+ applied = True
+ else:
+ logger.debug(
+ "SetProcessMitigationPolicy strict-handle failed: %s",
+ ctypes.get_last_error(),
+ )
+
+ if applied:
+ logger.info("Windows process mitigations applied under AppContainer")
+ return applied
+
+
+# Hook type for tests that inject fake launchers.
+LaunchBackendFn = Callable[..., LaunchResult]
diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py
index d3684a33..3943a6d5 100644
--- a/meshchatx/src/backend/database/__init__.py
+++ b/meshchatx/src/backend/database/__init__.py
@@ -140,25 +140,30 @@ class Database:
relax: bool,
*,
landlock_active: bool = False,
+ fs_sandbox_active: bool | None = None,
) -> bool:
"""Shrink SQLite cache under low RAM.
- FILE temp spills break complex conversation queries under Landlock
- (unable to open database file), even when TMPDIR is inside the
- allowed storage tree. Keep MEMORY temp while Landlock is active and
- only reduce cache/mmap. Without Landlock, FILE temp is still used.
+ FILE temp spills break complex conversation queries under filesystem
+ sandboxes (Landlock or Windows AppContainer), even when TMPDIR is
+ inside the allowed storage tree. Keep MEMORY temp while a FS sandbox
+ is active and only reduce cache/mmap. Without a sandbox, FILE temp
+ is still used.
"""
+ sandbox_active = (
+ landlock_active if fs_sandbox_active is None else bool(fs_sandbox_active)
+ )
try:
if relax:
self._ensure_sqlite_temp_dir()
- use_file_temp = not landlock_active
+ use_file_temp = not sandbox_active
self.provider.prefer_temp_store_file = use_file_temp
if use_file_temp:
self.execute_sql("PRAGMA temp_store=FILE")
else:
self.execute_sql("PRAGMA temp_store=MEMORY")
_log.info(
- "Memory pressure under Landlock: keeping temp_store=MEMORY",
+ "Memory pressure under FS sandbox: keeping temp_store=MEMORY",
)
self.execute_sql("PRAGMA cache_size=-2000") # 2 MB
self.execute_sql("PRAGMA mmap_size=0")
diff --git a/meshchatx/src/backend/memory_pressure.py b/meshchatx/src/backend/memory_pressure.py
index 18e863ce..5e6cdb25 100644
--- a/meshchatx/src/backend/memory_pressure.py
+++ b/meshchatx/src/backend/memory_pressure.py
@@ -20,6 +20,16 @@ HELD_ANNOUNCES_DROP_THRESHOLD = 512
ANNOUNCE_CACHE_CLEAN_INTERVAL_S = 30 * 60
+def _app_flag(app: Any, name: str) -> bool:
+ """Read a boolean flag without treating MagicMock defaults as True."""
+ if app is None:
+ return False
+ try:
+ return bool(object.__getattribute__(app, name))
+ except AttributeError:
+ return False
+
+
class MemoryPressureManager:
"""Coordinates link sweeps, path pruning, and SQLite disk offload."""
@@ -92,16 +102,21 @@ class MemoryPressureManager:
db = getattr(self.app, "database", None) if self.app else None
if db is not None and hasattr(db, "apply_memory_pressure_pragmas"):
try:
- landlock_active = bool(
- getattr(self.app, "landlock_active", False),
+ fs_sandbox_active = (
+ _app_flag(self.app, "fs_sandbox_active")
+ or _app_flag(
+ self.app,
+ "landlock_active",
+ )
+ or _app_flag(self.app, "appcontainer_active")
)
db.apply_memory_pressure_pragmas(
True,
- landlock_active=landlock_active,
+ fs_sandbox_active=fs_sandbox_active,
)
self._sqlite_relaxed = True
stats["sqlite_relaxed"] = True
- stats["sqlite_file_temp"] = not landlock_active
+ stats["sqlite_file_temp"] = not fs_sandbox_active
except Exception as exc:
_log.debug("SQLite pressure pragmas failed: %s", exc)
_log.warning(
diff --git a/meshchatx/src/backend/self_check.py b/meshchatx/src/backend/self_check.py
index 34ae6473..84722b7a 100644
--- a/meshchatx/src/backend/self_check.py
+++ b/meshchatx/src/backend/self_check.py
@@ -41,6 +41,7 @@ SELF_CHECK_LABELS = {
"imports_good": "Critical Imports ",
"storage_lock_good": "Storage Lock ",
"temp_fs_good": "Temp Filesystem ",
+ "fs_sandbox_good": "FS Sandbox Modules ",
"public_assets_good": "Public Assets ",
"lxmf_router_good": "LXMF Router ",
"subprocess_good": "Subprocess Spawn ",
@@ -271,6 +272,80 @@ def check_temp_filesystem() -> dict[str, str]:
os.unlink(path)
+def check_fs_sandbox() -> dict[str, str]:
+ """Verify Landlock/AppContainer/Seccomp helpers import and report status.
+
+ Does not enable sandboxes. Confirms modules load and exchange-folder
+ helpers create MeshChatX subdirs under a fake profile root.
+ """
+ try:
+ from meshchatx.src.backend import appcontainer_sandbox as ac
+ from meshchatx.src.backend import landlock_sandbox as ll
+ from meshchatx.src.backend import seccomp_sandbox as sc
+ except Exception as exc:
+ return _status(False, f"sandbox import failed: {exc}")
+
+ bool_checks = (
+ ("landlock_kernel_supported", ll.landlock_kernel_supported()),
+ ("landlock_requested", ll.landlock_requested()),
+ ("landlock_disabled_by_env", ll.landlock_disabled_by_env()),
+ ("appcontainer_supported", ac.appcontainer_supported()),
+ ("appcontainer_requested", ac.appcontainer_requested()),
+ ("appcontainer_disabled_by_env", ac.appcontainer_disabled_by_env()),
+ ("appcontainer_forced", ac.appcontainer_forced()),
+ ("is_appcontainer_child", ac.is_appcontainer_child()),
+ ("seccomp_kernel_supported", sc.seccomp_kernel_supported()),
+ ("seccomp_requested", sc.seccomp_requested()),
+ ("seccomp_disabled_by_env", sc.seccomp_disabled_by_env()),
+ )
+ for name, value in bool_checks:
+ if not isinstance(value, bool):
+ return _status(False, f"{name} returned non-bool: {type(value)!r}")
+
+ if ac.USER_EXCHANGE_DIR_NAME != "MeshChatX":
+ return _status(
+ False,
+ f"unexpected USER_EXCHANGE_DIR_NAME={ac.USER_EXCHANGE_DIR_NAME!r}",
+ )
+
+ try:
+ with tempfile.TemporaryDirectory(prefix="meshchatx_exchange_") as tmp:
+ documents = os.path.join(tmp, "Documents")
+ downloads = os.path.join(tmp, "Downloads")
+ pictures = os.path.join(tmp, "Pictures")
+ os.makedirs(documents)
+ os.makedirs(downloads)
+ os.makedirs(pictures)
+
+ original_profile = ac._user_profile_dir
+ original_known = ac._windows_known_folder
+ try:
+ ac._user_profile_dir = lambda: tmp # type: ignore[assignment]
+ ac._windows_known_folder = lambda _fid: None # type: ignore[assignment]
+ roots = ac.collect_user_exchange_roots(create=True)
+ finally:
+ ac._user_profile_dir = original_profile # type: ignore[assignment]
+ ac._windows_known_folder = original_known # type: ignore[assignment]
+
+ expected = {
+ os.path.join(documents, "MeshChatX"),
+ os.path.join(downloads, "MeshChatX"),
+ os.path.join(pictures, "MeshChatX"),
+ }
+ if set(roots) != expected:
+ return _status(
+ False,
+ f"exchange roots mismatch: got={roots!r} expected={sorted(expected)!r}",
+ )
+ for path in expected:
+ if not os.path.isdir(path):
+ return _status(False, f"exchange dir missing: {path}")
+ except Exception as exc:
+ return _status(False, f"exchange roots check failed: {exc}")
+
+ return _status(True)
+
+
def _is_frozen_executable() -> bool:
return bool(getattr(sys, "frozen", False))
@@ -882,7 +957,17 @@ async def _run_web_api_probes(app: Any) -> dict[str, dict[str, str]]:
validate=lambda body: (
None
if body.get("app_info", {}).get("version")
- else "app_info.version missing"
+ and isinstance(
+ body.get("app_info", {}).get("appcontainer_supported"), bool
+ )
+ and isinstance(
+ body.get("app_info", {}).get("landlock_active"), bool
+ )
+ and isinstance(
+ body.get("app_info", {}).get("fs_sandbox_active"), bool
+ )
+ and isinstance(body.get("app_info", {}).get("seccomp_active"), bool)
+ else "app_info missing version or FS sandbox status fields"
),
)
results["http_config_good"] = await _probe_json_get(
@@ -934,7 +1019,27 @@ async def _run_web_api_probes(app: Any) -> dict[str, dict[str, str]]:
results["http_security_good"] = await _probe_json_get(
client,
"/api/v1/server/security",
- require_keys=("listen_host", "listen_port", "auth_enabled"),
+ require_keys=(
+ "listen_host",
+ "listen_port",
+ "auth_enabled",
+ "landlock_kernel_supported",
+ "landlock_requested",
+ "landlock_auto_enabled",
+ "landlock_disabled_by_env",
+ "landlock_active",
+ "appcontainer_supported",
+ "appcontainer_requested",
+ "appcontainer_auto_enabled",
+ "appcontainer_disabled_by_env",
+ "appcontainer_active",
+ "fs_sandbox_active",
+ "seccomp_kernel_supported",
+ "seccomp_requested",
+ "seccomp_auto_enabled",
+ "seccomp_disabled_by_env",
+ "seccomp_active",
+ ),
)
results["http_interfaces_good"] = await _probe_json_get(
client,
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index ebead3c0..0ee4ff2b 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -2052,6 +2052,23 @@
: $t("app.landlock_inactive")
}}
</div>
+ <div
+ v-if="serverSecurity.appcontainer_requested !== undefined"
+ class="text-xs text-gray-600 dark:text-gray-400"
+ >
+ {{ $t("app.appcontainer_status") }}:
+ {{
+ serverSecurity.appcontainer_active
+ ? serverSecurity.appcontainer_auto_enabled
+ ? $t("app.appcontainer_auto_enabled")
+ : $t("app.appcontainer_active")
+ : serverSecurity.appcontainer_supported === false
+ ? $t("app.appcontainer_unsupported")
+ : serverSecurity.appcontainer_disabled_by_env
+ ? $t("app.appcontainer_disabled_by_env")
+ : $t("app.appcontainer_inactive")
+ }}
+ </div>
<div
v-if="serverSecurity.seccomp_requested !== undefined"
class="text-xs text-gray-600 dark:text-gray-400"
@@ -2979,6 +2996,11 @@ export default {
landlock_kernel_supported: false,
landlock_auto_enabled: false,
landlock_disabled_by_env: false,
+ appcontainer_requested: false,
+ appcontainer_active: false,
+ appcontainer_supported: false,
+ appcontainer_auto_enabled: false,
+ appcontainer_disabled_by_env: false,
seccomp_requested: false,
seccomp_active: false,
seccomp_kernel_supported: false,
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 5bb99ea7..ed87e81f 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -168,6 +168,12 @@
"landlock_auto_enabled": "Auf diesem Linux-Kernel automatisch aktiviert",
"landlock_kernel_unsupported": "Kernel unterstützt kein Landlock (5.13+ erforderlich)",
"landlock_disabled_by_env": "Deaktiviert über MESHCHAT_LANDLOCK=0",
+ "appcontainer_status": "AppContainer-Sandbox (Windows)",
+ "appcontainer_active": "Aktiv",
+ "appcontainer_inactive": "Nicht aktiv",
+ "appcontainer_auto_enabled": "Auf diesem Windows-Host automatisch aktiviert",
+ "appcontainer_unsupported": "AppContainer-APIs auf diesem Windows-Host nicht verfügbar",
+ "appcontainer_disabled_by_env": "Deaktiviert über MESHCHAT_APPCONTAINER=0",
"settings_map_eyebrow": "Karte",
"messages_description": "Steuern Sie, wie MeshChat fehlgeschlagene Zustellungen wiederholt oder eskaliert. Kontrollieren Sie das automatische Wiederholungsverhalten, die erneute Übertragung von Anhängen und Fallback-Mechanismen, um eine zuverlässige Nachrichtenzustellung über das Mesh-Netzwerk zu gewährleisten.",
"auto_resend_title": "Automatisch erneut senden, wenn Peer ankündigt",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index d65cf6a5..6af8e02b 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -224,6 +224,12 @@
"landlock_auto_enabled": "Enabled automatically on this Linux kernel",
"landlock_kernel_unsupported": "Kernel does not support Landlock (5.13+ required)",
"landlock_disabled_by_env": "Disabled via MESHCHAT_LANDLOCK=0",
+ "appcontainer_status": "AppContainer sandbox (Windows)",
+ "appcontainer_active": "Active",
+ "appcontainer_inactive": "Not active",
+ "appcontainer_auto_enabled": "Enabled automatically on this Windows host",
+ "appcontainer_unsupported": "AppContainer APIs unavailable on this Windows host",
+ "appcontainer_disabled_by_env": "Disabled via MESHCHAT_APPCONTAINER=0",
"seccomp_status": "Seccomp-BPF sandbox (Linux)",
"seccomp_active": "Active",
"seccomp_inactive": "Not active",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index bc84484f..d0500051 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -194,6 +194,12 @@
"landlock_auto_enabled": "Habilitado automáticamente en este kernel de Linux",
"landlock_kernel_unsupported": "El kernel no admite Landlock (se requiere 5.13+)",
"landlock_disabled_by_env": "Deshabilitado mediante MESHCHAT_LANDLOCK=0",
+ "appcontainer_status": "Sandbox AppContainer (Windows)",
+ "appcontainer_active": "Activo",
+ "appcontainer_inactive": "Inactivo",
+ "appcontainer_auto_enabled": "Activado automáticamente en este host Windows",
+ "appcontainer_unsupported": "APIs de AppContainer no disponibles en este host Windows",
+ "appcontainer_disabled_by_env": "Deshabilitado mediante MESHCHAT_APPCONTAINER=0",
"settings_map_eyebrow": "Mapa",
"messages_description": "Configure cómo MeshChat maneja fallos de entrega de mensajes. Controle el comportamiento automático de retry, la retransmisión de archivos adjuntos y los mecanismos de retroceso para asegurar una entrega fiable de mensajes a través de la red de malla.",
"auto_resend_title": "Reenviamiento automático cuando el par anuncia",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index c111b29a..ff0f3861 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -195,6 +195,12 @@
"landlock_auto_enabled": "Automaattisesti päällä tällä Linux-ytimellä",
"landlock_kernel_unsupported": "Ydin ei tue Landlockia (5.13+ vaaditaan)",
"landlock_disabled_by_env": "Poissa käytöstä asetuksella MESHCHAT_LANDLOCK=0",
+ "appcontainer_status": "AppContainer-hiekkalaatikko (Windows)",
+ "appcontainer_active": "Käytössä",
+ "appcontainer_inactive": "Ei käytössä",
+ "appcontainer_auto_enabled": "Käytössä automaattisesti tällä Windows-isännällä",
+ "appcontainer_unsupported": "AppContainer-API:t eivät ole käytettävissä tällä Windows-isännällä",
+ "appcontainer_disabled_by_env": "Poissa käytöstä asetuksella MESHCHAT_APPCONTAINER=0",
"settings_map_eyebrow": "Kartta",
"messages_description": "Määritä, miten MeshChatX käsittelee viestien toimitushäiriöitä. Hallitse automaattisen viestien ja liitteiden uudelleenlähetyksen käytäntöjä ja varamekanismeja luotettavan viestien lähettämisen takaamiseksi.",
"auto_resend_title": "Lähetä automaattisesti uudelleen, kun kohde kuuluttaa.",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 96b85261..05835d6d 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -194,6 +194,12 @@
"landlock_auto_enabled": "Activé automatiquement sur ce noyau Linux",
"landlock_kernel_unsupported": "Le noyau ne prend pas en charge Landlock (5.13+ requis)",
"landlock_disabled_by_env": "Désactivé via MESHCHAT_LANDLOCK=0",
+ "appcontainer_status": "Bac à sable AppContainer (Windows)",
+ "appcontainer_active": "Actif",
+ "appcontainer_inactive": "Inactif",
+ "appcontainer_auto_enabled": "Activé automatiquement sur cet hôte Windows",
+ "appcontainer_unsupported": "APIs AppContainer indisponibles sur cet hôte Windows",
+ "appcontainer_disabled_by_env": "Désactivé via MESHCHAT_APPCONTAINER=0",
"settings_map_eyebrow": "Carte",
"messages_description": "Configurez comment MeshChat gère les échecs de livraison des messages. Contrôler le comportement de réessayer automatique, la retransmission des attaches et les mécanismes de repli pour assurer la livraison fiable des messages sur le réseau de mailles.",
"auto_resend_title": "Auto renverra quand pair annoncera",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index b865eeb8..aacf3926 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -194,6 +194,12 @@
"landlock_auto_enabled": "Abilitato automaticamente su questo kernel Linux",
"landlock_kernel_unsupported": "Il kernel non supporta Landlock (richiesto 5.13+)",
"landlock_disabled_by_env": "Disabilitato tramite MESHCHAT_LANDLOCK=0",
+ "appcontainer_status": "Sandbox AppContainer (Windows)",
+ "appcontainer_active": "Attivo",
+ "appcontainer_inactive": "Non attivo",
+ "appcontainer_auto_enabled": "Abilitato automaticamente su questo host Windows",
+ "appcontainer_unsupported": "API AppContainer non disponibili su questo host Windows",
+ "appcontainer_disabled_by_env": "Disabilitato tramite MESHCHAT_APPCONTAINER=0",
"settings_map_eyebrow": "Mappa",
"messages_description": "Configura come MeshChat gestisce i fallimenti di consegna dei messaggi. Controlla il comportamento dei tentativi automatici, la ritrasmissione degli allegati e i meccanismi di fallback per garantire una consegna affidabile dei messaggi nella rete mesh.",
"auto_resend_title": "Invia automaticamente quando il peer annuncia",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index df3d1039..12d456bc 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -194,6 +194,12 @@
"landlock_auto_enabled": "Automatisch ingeschakeld op deze Linux-kernel",
"landlock_kernel_unsupported": "Kernel ondersteunt geen Landlock (5.13+ vereist)",
"landlock_disabled_by_env": "Uitgeschakeld via MESHCHAT_LANDLOCK=0",
+ "appcontainer_status": "AppContainer-sandbox (Windows)",
+ "appcontainer_active": "Actief",
+ "appcontainer_inactive": "Niet actief",
+ "appcontainer_auto_enabled": "Automatisch ingeschakeld op deze Windows-host",
+ "appcontainer_unsupported": "AppContainer-API's niet beschikbaar op deze Windows-host",
+ "appcontainer_disabled_by_env": "Uitgeschakeld via MESHCHAT_APPCONTAINER=0",
"settings_map_eyebrow": "Kaart",
"messages_description": "Configureer hoe MeshChat omgaat met foutieve berichtlevering. Controle automatische retry gedrag, bijlage doorgifte, en terugvalmechanismen om betrouwbare bericht levering over het mesh netwerk te garanderen.",
"auto_resend_title": "Automatisch opnieuw verzenden wanneer peer aankondigt",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 344ce6ca..a3575328 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -168,6 +168,12 @@
"landlock_auto_enabled": "Автоматически включена в этом ядре Linux",
"landlock_kernel_unsupported": "Ядро не поддерживает Landlock (требуется 5.13+)",
"landlock_disabled_by_env": "Отключено через MESHCHAT_LANDLOCK=0",
+ "appcontainer_status": "Песочница AppContainer (Windows)",
+ "appcontainer_active": "Активна",
+ "appcontainer_inactive": "Не активна",
+ "appcontainer_auto_enabled": "Включается автоматически на этом узле Windows",
+ "appcontainer_unsupported": "API AppContainer недоступны на этом узле Windows",
+ "appcontainer_disabled_by_env": "Отключено через MESHCHAT_APPCONTAINER=0",
"settings_map_eyebrow": "Карта",
"messages_description": "Настройте, как MeshChat повторяет или эскалирует неудачные доставки. Контролируйте автоматический повтор, пересылку вложений и механизмы отката для обеспечения надежной доставки сообщений в сети mesh.",
"auto_resend_title": "Автоповтор при анонсе узла",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 742dff60..4073f371 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -194,6 +194,12 @@
"landlock_auto_enabled": "在此 Linux 内核上自动启用",
"landlock_kernel_unsupported": "内核不支持 Landlock(需要 5.13+)",
"landlock_disabled_by_env": "已通过 MESHCHAT_LANDLOCK=0 禁用",
+ "appcontainer_status": "AppContainer 沙箱(Windows)",
+ "appcontainer_active": "已启用",
+ "appcontainer_inactive": "未启用",
+ "appcontainer_auto_enabled": "在此 Windows 主机上自动启用",
+ "appcontainer_unsupported": "此 Windows 主机上 AppContainer API 不可用",
+ "appcontainer_disabled_by_env": "已通过 MESHCHAT_APPCONTAINER=0 禁用",
"settings_map_eyebrow": "地图",
"messages_description": "配置 MeshChat 如何处理消息发送失败。控制自动重试行为、附件重传和回退机制,以确保通过网格网络可靠地发送消息。",
"auto_resend_title": "当节点广播时自动重发",
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/windows-sandbox.md b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/windows-sandbox.md
new file mode 100644
index 00000000..c433218f
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/windows-sandbox.md
@@ -0,0 +1,84 @@
+# Windows AppContainer sandbox (Landlock equivalent)
+
+MeshChatX on **Windows 10 and Windows 11** can run the backend inside a **Less Privileged AppContainer (LPAC)**. This is the Windows counterpart to Linux **Landlock**: the process loses ambient write access to the user profile and only keeps write access under explicitly granted directories.
+
+Unlike Landlock, Windows cannot lock down an already-running process the same way. Electron therefore starts the frozen backend through a small launcher module that:
+
+1. Creates or reuses the `MeshChatX.Backend` AppContainer profile
+2. Grants the package SID read/write ACLs on storage, Reticulum config, logs, temp, and app-owned exchange folders
+3. Grants read/execute on the packaged backend tree (needed under LPAC)
+4. Starts `ReticulumMeshChatX.exe` inside the container with network capabilities for Reticulum
+5. Waits for exit and revokes those ACL grants
+
+## What is allowed
+
+**Writable**
+
+- MeshChatX storage (`--storage-dir` / `%USERPROFILE%\.reticulum-meshchatx` or portable sibling)
+- Reticulum config (`--reticulum-config-dir`)
+- Log directory (`MESHCHAT_LOG_DIR`, usually `storage/logs`)
+- Process temp (`%TEMP%` / `%TMP%`)
+- App-owned exchange folders (created if missing):
+ - `Documents\MeshChatX`
+ - `Downloads\MeshChatX`
+ - `Pictures\MeshChatX`
+
+These exchange folders are for attachments and exports. The sandbox does **not** grant access to all of Documents, Downloads, or Pictures.
+
+Electron sets the default download path to `Downloads\MeshChatX` so UI saves land in the same exchange dir the backend can use.
+
+**Readable (execute tree)**
+
+- Packaged backend directory next to `ReticulumMeshChatX.exe`
+
+**Network**
+
+- Internet and private-network AppContainer capabilities are granted so RNS TCP/UDP interfaces keep working. There is no per-host or per-port firewall in this layer.
+
+**Not writable**
+
+- Desktop, and the rest of Documents / Downloads / Pictures outside the `MeshChatX` subfolders
+- Arbitrary profile paths and drive roots
+
+Attaching a file from elsewhere still works through the Electron/renderer file picker (bytes uploaded over local HTTPS). The backend does not need to open the original Documents path for that flow.
+
+## Environment override
+
+| Env | Effect |
+| --- | --- |
+| unset | Auto-enable on Windows when AppContainer APIs are available (Electron desktop default on) |
+| `MESHCHAT_APPCONTAINER=0` | Disable (direct backend spawn, no launcher) |
+| `MESHCHAT_APPCONTAINER=1` | Force AppContainer. Launch fails hard if APIs or CreateProcess fail |
+
+The sandboxed child sets `MESHCHAT_APPCONTAINER_CHILD=1`. Settings → Network exposure shows AppContainer status next to Landlock/Seccomp.
+
+## Portable installs
+
+When `PORTABLE_EXECUTABLE_DIR` is set, storage and Reticulum dirs live beside the portable exe. The launcher grants ACLs on those portable paths, not only under `%USERPROFILE%`. Exchange folders still use the current user's Documents/Downloads/Pictures Known Folders (including OneDrive redirects when available).
+
+## SQLite
+
+Under AppContainer (same as Landlock), MeshChatX keeps `PRAGMA temp_store=MEMORY` during memory pressure so spill files are not required outside the jail.
+
+## Disable / troubleshoot
+
+- Set `MESHCHAT_APPCONTAINER=0` and restart if a DLL fails to load under LPAC.
+- If forced mode fails, check logs for CreateProcess or ACL errors.
+- Storage directory changes require a backend restart so ACL grants match the new roots.
+- Orphan cleanup uses `taskkill /T` on the launcher PID so the AppContainer child is included.
+
+## Relation to other layers
+
+- Electron renderer still uses Chromium sandbox / fuses.
+- Plugin RSG and path jail remain in force inside the container.
+- Linux Landlock and Seccomp are unchanged and unused on Windows.
+
+## Manual smoke checklist (Win10 / Win11)
+
+1. Packaged app starts and `/api/v1/status` reports `appcontainer_active: true`
+2. Local HTTPS UI on port 9337 loads
+3. Messages and RNS interfaces still work
+4. Writing a probe file under storage succeeds
+5. Writing under `Documents\MeshChatX` succeeds
+6. Writing under Desktop or bare Documents from a debug hook fails
+7. Quitting Electron stops launcher and child
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/manifest.json b/meshchatx/src/frontend/public/meshchatx-docs/manifest.json
index 8a35c4f8..706558e3 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/manifest.json
+++ b/meshchatx/src/frontend/public/meshchatx-docs/manifest.json
@@ -108,6 +108,11 @@
"path": "en/platform-guides/linux-sandbox.md",
"lang": "en",
"title": { "en": "Linux sandboxing" }
+ },
+ {
+ "path": "en/platform-guides/windows-sandbox.md",
+ "lang": "en",
+ "title": { "en": "Windows AppContainer sandbox" }
}
]
}
diff --git a/scripts/ci/github-build-windows.sh b/scripts/ci/github-build-windows.sh
index 4e292fb2..36777f85 100755
--- a/scripts/ci/github-build-windows.sh
+++ b/scripts/ci/github-build-windows.sh
@@ -25,3 +25,13 @@ fi
bash scripts/ci/github-prune-electron-dist-staging.sh
bash scripts/ci/github-verify-electron-dist.sh win
+
+# Ensure AppContainer/Landlock/Seccomp modules shipped in the frozen backend.
+if [[ -d build/exe ]]; then
+ bash scripts/ci/github-verify-frozen-sandbox.sh build/exe
+fi
+
+# Optional packaged smoke (manual / future CI job on a Windows runner):
+# MESHCHAT_APPCONTAINER=1 start the portable exe and assert
+# GET /api/v1/server/security reports appcontainer_active true.
+# Disable with MESHCHAT_APPCONTAINER=0 if LPAC DLL load fails on a given host.
diff --git a/scripts/ci/github-verify-frozen-sandbox.sh b/scripts/ci/github-verify-frozen-sandbox.sh
new file mode 100755
index 00000000..9d06e810
--- /dev/null
+++ b/scripts/ci/github-verify-frozen-sandbox.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Verify cx_Freeze bundles Landlock / AppContainer / Seccomp sandbox modules.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+BUILD_EXE="${1:-${ROOT}/build/exe}"
+
+if [[ ! -d "${BUILD_EXE}" ]]; then
+ echo "cx_Freeze output not found at ${BUILD_EXE}" >&2
+ exit 1
+fi
+
+if [[ ! -d "${BUILD_EXE}/lib" ]]; then
+ for sub in "${BUILD_EXE}"/*; do
+ if [[ -d "${sub}/lib" ]]; then
+ BUILD_EXE="${sub}"
+ break
+ fi
+ done
+fi
+
+LIB_DIR="${BUILD_EXE}/lib"
+REQUIRED=(
+ "meshchatx/src/backend/landlock_sandbox.py"
+ "meshchatx/src/backend/appcontainer_sandbox.py"
+ "meshchatx/src/backend/appcontainer_launcher.py"
+ "meshchatx/src/backend/seccomp_sandbox.py"
+)
+
+missing=0
+for rel in "${REQUIRED[@]}"; do
+ if [[ -f "${LIB_DIR}/${rel}" ]]; then
+ echo "found ${rel}"
+ continue
+ fi
+ LIBRARY_ZIP="${LIB_DIR}/library.zip"
+ if [[ -f "${LIBRARY_ZIP}" ]] && unzip -l "${LIBRARY_ZIP}" | grep -Fq "${rel}"; then
+ echo "found ${rel} in library.zip"
+ continue
+ fi
+ echo "missing sandbox module: ${rel}" >&2
+ missing=1
+done
+
+if [[ "${missing}" -ne 0 ]]; then
+ exit 1
+fi
+
+echo "FS sandbox modules present under ${BUILD_EXE}"
diff --git a/tests/backend/api_json_contract_schemas.py b/tests/backend/api_json_contract_schemas.py
index 0ff90e40..912470de 100644
--- a/tests/backend/api_json_contract_schemas.py
+++ b/tests/backend/api_json_contract_schemas.py
@@ -160,6 +160,12 @@ APP_INFO_BODY_SCHEMA: dict = {
"landlock_auto_enabled": {"type": "boolean"},
"landlock_disabled_by_env": {"type": "boolean"},
"landlock_active": {"type": "boolean"},
+ "appcontainer_supported": {"type": "boolean"},
+ "appcontainer_requested": {"type": "boolean"},
+ "appcontainer_auto_enabled": {"type": "boolean"},
+ "appcontainer_disabled_by_env": {"type": "boolean"},
+ "appcontainer_active": {"type": "boolean"},
+ "fs_sandbox_active": {"type": "boolean"},
"seccomp_kernel_supported": {"type": "boolean"},
"seccomp_requested": {"type": "boolean"},
"seccomp_auto_enabled": {"type": "boolean"},
@@ -180,6 +186,12 @@ _SERVER_BIND_STATUS_SCHEMA: dict = {
"landlock_auto_enabled": {"type": "boolean"},
"landlock_disabled_by_env": {"type": "boolean"},
"landlock_active": {"type": "boolean"},
+ "appcontainer_supported": {"type": "boolean"},
+ "appcontainer_requested": {"type": "boolean"},
+ "appcontainer_auto_enabled": {"type": "boolean"},
+ "appcontainer_disabled_by_env": {"type": "boolean"},
+ "appcontainer_active": {"type": "boolean"},
+ "fs_sandbox_active": {"type": "boolean"},
"seccomp_kernel_supported": {"type": "boolean"},
"seccomp_requested": {"type": "boolean"},
"seccomp_auto_enabled": {"type": "boolean"},
diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py
index 91f2dd71..3244c0bc 100644
--- a/tests/backend/conftest.py
+++ b/tests/backend/conftest.py
@@ -5,6 +5,8 @@ import os
# Disable Landlock sandbox globally during backend testing to prevent process lockdown and PermissionError crashes.
os.environ["MESHCHAT_LANDLOCK"] = "0"
+# Disable Windows AppContainer launcher path in tests (no-op on Linux, safe on Windows CI).
+os.environ["MESHCHAT_APPCONTAINER"] = "0"
import socket
from contextlib import ExitStack
diff --git a/tests/backend/test_api_json_contracts.py b/tests/backend/test_api_json_contracts.py
index be19c66c..0670b543 100644
--- a/tests/backend/test_api_json_contracts.py
+++ b/tests/backend/test_api_json_contracts.py
@@ -175,6 +175,12 @@ def test_status_schema_accepts_starting_and_failed_envelopes():
"landlock_auto_enabled": False,
"landlock_disabled_by_env": False,
"landlock_active": False,
+ "appcontainer_supported": False,
+ "appcontainer_requested": False,
+ "appcontainer_auto_enabled": False,
+ "appcontainer_disabled_by_env": False,
+ "appcontainer_active": False,
+ "fs_sandbox_active": False,
"seccomp_kernel_supported": False,
"seccomp_requested": False,
"seccomp_auto_enabled": False,
diff --git a/tests/backend/test_appcontainer_sandbox.py b/tests/backend/test_appcontainer_sandbox.py
new file mode 100644
index 00000000..70e92fae
--- /dev/null
+++ b/tests/backend/test_appcontainer_sandbox.py
@@ -0,0 +1,260 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Unit tests for Windows AppContainer sandbox helpers."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+
+from meshchatx.src.backend import appcontainer_sandbox as ac
+from meshchatx.src.backend import appcontainer_launcher as launcher
+
+
+@pytest.fixture(autouse=True)
+def _reset_support_cache():
+ ac._appcontainer_support_cached = None
+ yield
+ ac._appcontainer_support_cached = None
+
+
+def test_appcontainer_supported_false_on_non_windows():
+ with patch.object(ac, "sys") as mock_sys:
+ mock_sys.platform = "linux"
+ assert ac.appcontainer_supported() is False
+ assert ac.appcontainer_requested() is False
+
+
+def test_appcontainer_disabled_by_env(monkeypatch):
+ monkeypatch.setenv("MESHCHAT_APPCONTAINER", "0")
+ with patch.object(ac, "sys") as mock_sys:
+ mock_sys.platform = "win32"
+ assert ac.appcontainer_requested() is False
+ assert ac.appcontainer_disabled_by_env() is True
+ assert ac.appcontainer_forced() is False
+
+
+def test_appcontainer_forced_env(monkeypatch):
+ monkeypatch.setenv("MESHCHAT_APPCONTAINER", "1")
+ with patch.object(ac, "sys") as mock_sys:
+ mock_sys.platform = "win32"
+ assert ac.appcontainer_requested() is True
+ assert ac.appcontainer_auto_enabled() is False
+ assert ac.appcontainer_forced() is True
+
+
+def test_appcontainer_auto_when_supported(monkeypatch):
+ monkeypatch.delenv("MESHCHAT_APPCONTAINER", raising=False)
+ with (
+ patch.object(ac, "sys") as mock_sys,
+ patch.object(ac, "appcontainer_supported", return_value=True),
+ ):
+ mock_sys.platform = "win32"
+ assert ac.appcontainer_requested() is True
+ assert ac.appcontainer_auto_enabled() is True
+
+
+def test_is_appcontainer_child(monkeypatch):
+ monkeypatch.delenv(ac.CHILD_ENV_FLAG, raising=False)
+ assert ac.is_appcontainer_child() is False
+ monkeypatch.setenv(ac.CHILD_ENV_FLAG, "1")
+ assert ac.is_appcontainer_child() is True
+
+
+def test_collect_rw_roots_includes_storage_and_temp(tmp_path, monkeypatch):
+ storage = tmp_path / "storage"
+ reticulum = tmp_path / "reticulum"
+ logs = storage / "logs"
+ storage.mkdir()
+ reticulum.mkdir()
+ logs.mkdir()
+ fake_temp = tmp_path / "tmp"
+ fake_temp.mkdir()
+ monkeypatch.setattr(ac.tempfile, "gettempdir", lambda: str(fake_temp))
+ monkeypatch.setattr(ac, "collect_user_exchange_roots", lambda create=True: [])
+ roots = ac.collect_rw_roots(str(storage), str(reticulum), str(logs))
+ assert str(storage) in roots
+ assert str(reticulum) in roots
+ assert str(logs) in roots
+ assert str(fake_temp) in roots
+
+
+def test_collect_user_exchange_roots_creates_meshchatx_subdirs(tmp_path, monkeypatch):
+ documents = tmp_path / "Documents"
+ downloads = tmp_path / "Downloads"
+ pictures = tmp_path / "Pictures"
+ documents.mkdir()
+ downloads.mkdir()
+ pictures.mkdir()
+ monkeypatch.setattr(ac, "_user_profile_dir", lambda: str(tmp_path))
+ monkeypatch.setattr(ac, "_windows_known_folder", lambda _fid: None)
+ roots = ac.collect_user_exchange_roots(create=True)
+ assert str(documents / "MeshChatX") in roots
+ assert str(downloads / "MeshChatX") in roots
+ assert str(pictures / "MeshChatX") in roots
+ assert (documents / "MeshChatX").is_dir()
+ assert (downloads / "MeshChatX").is_dir()
+ assert (pictures / "MeshChatX").is_dir()
+
+
+def test_collect_rw_roots_includes_exchange_dirs(tmp_path, monkeypatch):
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ exchange = tmp_path / "Downloads" / "MeshChatX"
+ monkeypatch.setattr(ac.tempfile, "gettempdir", lambda: str(tmp_path / "tmp"))
+ (tmp_path / "tmp").mkdir()
+ monkeypatch.setattr(
+ ac,
+ "collect_user_exchange_roots",
+ lambda create=True: [str(exchange)],
+ )
+ exchange.mkdir(parents=True)
+ roots = ac.collect_rw_roots(str(storage), str(storage), str(storage / "logs"))
+ assert str(exchange) in roots
+ assert str(tmp_path / "Downloads") not in roots
+
+
+def test_collect_ro_roots_includes_exe_dir(tmp_path):
+ exe_dir = tmp_path / "backend"
+ exe_dir.mkdir()
+ roots = ac.collect_ro_roots(exe_dir=str(exe_dir))
+ assert str(exe_dir) in roots
+
+
+def test_build_command_line_quotes_spaces():
+ line = ac._build_command_line(
+ r"C:\Program Files\app.exe", ["--storage-dir", r"C:\Users\a b\data"]
+ )
+ assert '"C:\\Program Files\\app.exe"' in line
+ assert '"C:\\Users\\a b\\data"' in line
+
+
+def test_launcher_rejects_non_windows(monkeypatch):
+ monkeypatch.setattr(launcher.sys, "platform", "linux")
+ code = launcher.run_launcher(["--headless", "--port", "9337"])
+ assert code == 2
+
+
+def test_launcher_rejects_nested_child(monkeypatch):
+ monkeypatch.setattr(launcher.sys, "platform", "win32")
+ monkeypatch.setattr(launcher, "is_appcontainer_child", lambda: True)
+ code = launcher.run_launcher(["--headless"])
+ assert code == 2
+
+
+def test_launcher_forced_fails_when_unsupported(monkeypatch):
+ monkeypatch.setattr(launcher.sys, "platform", "win32")
+ monkeypatch.setattr(launcher, "is_appcontainer_child", lambda: False)
+ monkeypatch.setattr(launcher, "appcontainer_supported", lambda: False)
+ monkeypatch.setattr(launcher, "appcontainer_forced", lambda: True)
+ code = launcher.run_launcher(["--headless"])
+ assert code == 2
+
+
+def test_launcher_success_path(monkeypatch, tmp_path):
+ monkeypatch.setattr(launcher.sys, "platform", "win32")
+ monkeypatch.setattr(
+ launcher.sys, "executable", str(tmp_path / "ReticulumMeshChatX.exe")
+ )
+ monkeypatch.setattr(launcher, "is_appcontainer_child", lambda: False)
+ monkeypatch.setattr(launcher, "appcontainer_supported", lambda: True)
+ monkeypatch.setattr(launcher, "appcontainer_forced", lambda: False)
+
+ def fake_launch(exe, args, **kwargs):
+ assert exe.endswith("ReticulumMeshChatX.exe")
+ assert "--headless" in args
+ return ac.LaunchResult(ok=True, exit_code=0, used_appcontainer=True)
+
+ monkeypatch.setattr(launcher, "launch_backend_sandboxed", fake_launch)
+ code = launcher.run_launcher(
+ ["--headless", "--port", "9337", "--storage-dir", str(tmp_path)]
+ )
+ assert code == 0
+
+
+def test_launcher_reports_launch_failure(monkeypatch, tmp_path):
+ monkeypatch.setattr(launcher.sys, "platform", "win32")
+ monkeypatch.setattr(
+ launcher.sys, "executable", str(tmp_path / "ReticulumMeshChatX.exe")
+ )
+ monkeypatch.setattr(launcher, "is_appcontainer_child", lambda: False)
+ monkeypatch.setattr(launcher, "appcontainer_supported", lambda: True)
+ monkeypatch.setattr(launcher, "appcontainer_forced", lambda: True)
+ monkeypatch.setattr(
+ launcher,
+ "launch_backend_sandboxed",
+ lambda *a, **k: ac.LaunchResult(ok=False, error="boom"),
+ )
+ code = launcher.run_launcher(["--headless"])
+ assert code == 1
+
+
+def test_apply_mitigations_noop_outside_child(monkeypatch):
+ monkeypatch.setattr(ac, "is_appcontainer_child", lambda: False)
+ with patch.object(ac, "sys") as mock_sys:
+ mock_sys.platform = "win32"
+ assert ac.apply_windows_process_mitigations() is False
+
+
+def test_launch_backend_forced_no_fallback(monkeypatch, tmp_path):
+ storage = tmp_path / "storage"
+ storage.mkdir()
+
+ monkeypatch.setattr(ac, "ensure_appcontainer_profile", lambda: object())
+ monkeypatch.setattr(ac, "grant_path_access", lambda *a, **k: None)
+ monkeypatch.setattr(ac, "revoke_path_access", lambda *a, **k: None)
+ monkeypatch.setattr(
+ ac,
+ "create_process_in_appcontainer",
+ lambda *a, **k: (_ for _ in ()).throw(OSError("create failed")),
+ )
+ monkeypatch.setattr(ac, "collect_ro_roots", lambda **k: [])
+
+ result = ac.launch_backend_sandboxed(
+ str(tmp_path / "exe"),
+ ["--headless"],
+ storage_dir=str(storage),
+ reticulum_config_dir=str(storage),
+ log_dir=str(storage / "logs"),
+ forced=True,
+ )
+ assert result.ok is False
+ assert result.fell_back is False
+ assert "create failed" in (result.error or "")
+
+
+def test_launch_backend_auto_fallback(monkeypatch, tmp_path):
+ storage = tmp_path / "storage"
+ storage.mkdir()
+ sid = object()
+
+ monkeypatch.setattr(ac, "ensure_appcontainer_profile", lambda: sid)
+ monkeypatch.setattr(ac, "grant_path_access", lambda *a, **k: None)
+ monkeypatch.setattr(ac, "revoke_path_access", lambda *a, **k: None)
+ monkeypatch.setattr(
+ ac,
+ "create_process_in_appcontainer",
+ lambda *a, **k: (_ for _ in ()).throw(OSError("create failed")),
+ )
+ monkeypatch.setattr(
+ ac,
+ "create_process_unsandboxed",
+ lambda *a, **k: (1, 2, 99),
+ )
+ monkeypatch.setattr(ac, "close_handle", lambda *a, **k: None)
+ monkeypatch.setattr(ac, "_wait_process", lambda *a, **k: 0)
+ monkeypatch.setattr(ac, "collect_ro_roots", lambda **k: [])
+
+ result = ac.launch_backend_sandboxed(
+ str(tmp_path / "exe"),
+ ["--headless"],
+ storage_dir=str(storage),
+ reticulum_config_dir=str(storage),
+ log_dir=str(storage / "logs"),
+ forced=False,
+ )
+ assert result.ok is True
+ assert result.fell_back is True
+ assert result.used_appcontainer is False
+ assert result.exit_code == 0
diff --git a/tests/backend/test_deferred_network_startup.py b/tests/backend/test_deferred_network_startup.py
index 8a97dd13..ff80d9e2 100644
--- a/tests/backend/test_deferred_network_startup.py
+++ b/tests/backend/test_deferred_network_startup.py
@@ -398,6 +398,12 @@ def test_status_schema_fuzz_valid_envelopes(status, stage, network_ready, error)
"landlock_auto_enabled": False,
"landlock_disabled_by_env": False,
"landlock_active": False,
+ "appcontainer_supported": False,
+ "appcontainer_requested": False,
+ "appcontainer_auto_enabled": False,
+ "appcontainer_disabled_by_env": False,
+ "appcontainer_active": False,
+ "fs_sandbox_active": False,
"seccomp_kernel_supported": False,
"seccomp_requested": False,
"seccomp_auto_enabled": False,
diff --git a/tests/backend/test_memory_pressure.py b/tests/backend/test_memory_pressure.py
index 00ccb2dc..85f684c4 100644
--- a/tests/backend/test_memory_pressure.py
+++ b/tests/backend/test_memory_pressure.py
@@ -61,12 +61,13 @@ def test_on_memory_low_relaxes_sqlite():
app = MagicMock()
app.database = MagicMock()
app.landlock_active = False
+ app.appcontainer_active = False
manager = MemoryPressureManager(app=app)
with patch.object(manager, "run_periodic_cleanup", return_value={"ok": True}):
stats = manager.on_memory_low(50.0)
app.database.apply_memory_pressure_pragmas.assert_called_once_with(
True,
- landlock_active=False,
+ fs_sandbox_active=False,
)
assert stats["sqlite_relaxed"] is True
assert stats["sqlite_file_temp"] is True
@@ -78,12 +79,28 @@ def test_on_memory_low_keeps_memory_temp_when_landlock_active():
app = MagicMock()
app.database = MagicMock()
app.landlock_active = True
+ app.appcontainer_active = False
manager = MemoryPressureManager(app=app)
with patch.object(manager, "run_periodic_cleanup", return_value={"ok": True}):
stats = manager.on_memory_low(50.0)
app.database.apply_memory_pressure_pragmas.assert_called_once_with(
True,
- landlock_active=True,
+ fs_sandbox_active=True,
)
assert stats["sqlite_relaxed"] is True
assert stats["sqlite_file_temp"] is False
+
+
+def test_on_memory_low_keeps_memory_temp_when_appcontainer_active():
+ app = MagicMock()
+ app.database = MagicMock()
+ app.landlock_active = False
+ app.appcontainer_active = True
+ manager = MemoryPressureManager(app=app)
+ with patch.object(manager, "run_periodic_cleanup", return_value={"ok": True}):
+ stats = manager.on_memory_low(50.0)
+ app.database.apply_memory_pressure_pragmas.assert_called_once_with(
+ True,
+ fs_sandbox_active=True,
+ )
+ assert stats["sqlite_file_temp"] is False
diff --git a/tests/backend/test_self_check.py b/tests/backend/test_self_check.py
index be2e133d..d2d4af92 100644
--- a/tests/backend/test_self_check.py
+++ b/tests/backend/test_self_check.py
@@ -65,6 +65,11 @@ def test_check_temp_filesystem_ok():
assert self_check.check_temp_filesystem()["status"] == "ok"
+def test_check_fs_sandbox_ok():
+ result = self_check.check_fs_sandbox()
+ assert result["status"] == "ok", result.get("reason")
+
+
def test_check_public_assets_ok(tmp_path):
(tmp_path / "index.html").write_text("<html></html>", encoding="utf-8")
diff --git a/tests/electron/backendProcess.test.js b/tests/electron/backendProcess.test.js
index 21c98f4f..3902b880 100644
--- a/tests/electron/backendProcess.test.js
+++ b/tests/electron/backendProcess.test.js
@@ -98,4 +98,72 @@ describe("electron/backendProcess", () => {
expect(notifyRenderer).toHaveBeenCalled();
expect(showCrashPage).toHaveBeenCalledWith(expect.objectContaining({ code: 1 }));
});
+
+ it("wraps win32 spawn through the AppContainer launcher by default", async () => {
+ const previousPlatform = process.platform;
+ Object.defineProperty(process, "platform", { value: "win32" });
+ delete process.env.MESHCHAT_APPCONTAINER;
+ try {
+ const manager = createBackendProcessManager({
+ log: vi.fn(),
+ getDefaultStorageDir: () => "C:\\Users\\test\\.reticulum-meshchatx",
+ getDefaultReticulumConfigDir: () => "C:\\Users\\test\\.reticulum",
+ getMainWindowPageKind: () => "loading",
+ isQuiting: () => false,
+ notifyRenderer: vi.fn(),
+ showCrashPage: vi.fn(),
+ spawn: spawnMock,
+ });
+ manager.setUserProvidedArguments([]);
+ await manager.spawnBackend("C:\\App\\ReticulumMeshChatX.exe", {
+ backend: { ok: true, issues: [] },
+ });
+ expect(spawnMock).toHaveBeenCalledWith(
+ "C:\\App\\ReticulumMeshChatX.exe",
+ expect.arrayContaining([
+ "--meshchatx-run-module",
+ "meshchatx.src.backend.appcontainer_launcher",
+ "--headless",
+ "--port",
+ "9337",
+ ]),
+ expect.objectContaining({ windowsHide: true })
+ );
+ } finally {
+ Object.defineProperty(process, "platform", { value: previousPlatform });
+ }
+ });
+
+ it("skips AppContainer launcher when MESHCHAT_APPCONTAINER=0 on win32", async () => {
+ const previousPlatform = process.platform;
+ const previousEnv = process.env.MESHCHAT_APPCONTAINER;
+ Object.defineProperty(process, "platform", { value: "win32" });
+ process.env.MESHCHAT_APPCONTAINER = "0";
+ try {
+ const manager = createBackendProcessManager({
+ log: vi.fn(),
+ getDefaultStorageDir: () => "C:\\Users\\test\\.reticulum-meshchatx",
+ getDefaultReticulumConfigDir: () => "C:\\Users\\test\\.reticulum",
+ getMainWindowPageKind: () => "loading",
+ isQuiting: () => false,
+ notifyRenderer: vi.fn(),
+ showCrashPage: vi.fn(),
+ spawn: spawnMock,
+ });
+ manager.setUserProvidedArguments([]);
+ await manager.spawnBackend("C:\\App\\ReticulumMeshChatX.exe", {
+ backend: { ok: true, issues: [] },
+ });
+ const args = spawnMock.mock.calls[0][1];
+ expect(args).not.toContain("--meshchatx-run-module");
+ expect(args[0]).toBe("--headless");
+ } finally {
+ Object.defineProperty(process, "platform", { value: previousPlatform });
+ if (previousEnv === undefined) {
+ delete process.env.MESHCHAT_APPCONTAINER;
+ } else {
+ process.env.MESHCHAT_APPCONTAINER = previousEnv;
+ }
+ }
+ });
});
diff --git a/tests/electron/shellPathGuard.test.js b/tests/electron/shellPathGuard.test.js
index 33cf5cbf..4fa137ff 100644
--- a/tests/electron/shellPathGuard.test.js
+++ b/tests/electron/shellPathGuard.test.js
@@ -22,6 +22,9 @@ function fakeApp() {
if (name === "documents") {
return path.join(home, "Documents");
}
+ if (name === "pictures") {
+ return path.join(home, "Pictures");
+ }
throw new Error(`unexpected getPath ${name}`);
},
};
@@ -49,6 +52,15 @@ describe("shellPathGuard", () => {
expect(isAllowedShellPath(p, ctx)).toBe(true);
});
+ it("allows MeshChatX exchange folders under downloads and documents", () => {
+ const downloadExchange = path.join(home, "Downloads", "MeshChatX", "attachment.bin");
+ const documentExchange = path.join(home, "Documents", "MeshChatX", "export.zip");
+ const pictureExchange = path.join(home, "Pictures", "MeshChatX", "shot.png");
+ expect(isAllowedShellPath(downloadExchange, ctx)).toBe(true);
+ expect(isAllowedShellPath(documentExchange, ctx)).toBe(true);
+ expect(isAllowedShellPath(pictureExchange, ctx)).toBe(true);
+ });
+
it("denies paths outside allowed roots", () => {
const p = process.platform === "win32" ? "C:\\Windows\\System32\\drivers\\etc\\hosts" : "/etc/passwd";
expect(isAllowedShellPath(p, ctx)).toBe(false);
diff --git a/tests/frontend/i18n.test.js b/tests/frontend/i18n.test.js
index f6237450..b0d91cc0 100644
--- a/tests/frontend/i18n.test.js
+++ b/tests/frontend/i18n.test.js
@@ -134,4 +134,39 @@ describe("i18n Localization Tests", () => {
}
}
});
+
+ it("keeps FS sandbox status keys present in every locale", () => {
+ const required = [
+ "app.landlock_status",
+ "app.landlock_active",
+ "app.landlock_inactive",
+ "app.landlock_auto_enabled",
+ "app.landlock_kernel_unsupported",
+ "app.landlock_disabled_by_env",
+ "app.appcontainer_status",
+ "app.appcontainer_active",
+ "app.appcontainer_inactive",
+ "app.appcontainer_auto_enabled",
+ "app.appcontainer_unsupported",
+ "app.appcontainer_disabled_by_env",
+ "app.seccomp_status",
+ "app.seccomp_active",
+ "app.seccomp_inactive",
+ "app.seccomp_auto_enabled",
+ "app.seccomp_kernel_unsupported",
+ "app.seccomp_disabled_by_env",
+ ];
+ for (const key of required) {
+ const parts = key.split(".");
+ for (const [code, data] of Object.entries(allLocales)) {
+ let current = data;
+ for (const part of parts) {
+ expect(current?.[part], `${code} missing ${key}`).toBeDefined();
+ current = current[part];
+ }
+ expect(typeof current, `${code} ${key} type`).toBe("string");
+ expect(String(current).trim().length, `${code} ${key} empty`).toBeGreaterThan(0);
+ }
+ }
+ });
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────